Merge pull request #35 from KGAudioLab/feat/2026-04-30-midi-device-recording
Feat/2026 04 30 midi device recording
This commit is contained in:
+3
-1
@@ -39,6 +39,7 @@
|
|||||||
"hold_to_create_region": "ctrl",
|
"hold_to_create_region": "ctrl",
|
||||||
"play": "space",
|
"play": "space",
|
||||||
"loop": "c",
|
"loop": "c",
|
||||||
|
"record": "r",
|
||||||
"undo": "ctrl+z",
|
"undo": "ctrl+z",
|
||||||
"redo": "ctrl+shift+z",
|
"redo": "ctrl+shift+z",
|
||||||
"select_all": "ctrl+a",
|
"select_all": "ctrl+a",
|
||||||
@@ -73,7 +74,8 @@
|
|||||||
"audio": {
|
"audio": {
|
||||||
"enable_audio_capture_for_screen_sharing": false,
|
"enable_audio_capture_for_screen_sharing": false,
|
||||||
"lookahead_time": 0.05,
|
"lookahead_time": 0.05,
|
||||||
"playback_delay": 0.2
|
"playback_delay": 0.2,
|
||||||
|
"recording_offset": 0
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"custom_instructions": ""
|
"custom_instructions": ""
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
|
|||||||
import { KGCore } from './core/KGCore';
|
import { KGCore } from './core/KGCore';
|
||||||
import { ConfigManager } from './core/config/ConfigManager';
|
import { ConfigManager } from './core/config/ConfigManager';
|
||||||
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||||
import { showAlert } from './components/common/DialogProvider';
|
import { showAlert } from './util/dialogUtil';
|
||||||
import { KGProjectStorage } from './core/io/KGProjectStorage';
|
import { KGProjectStorage } from './core/io/KGProjectStorage';
|
||||||
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
|
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { sliceAudioToWav } from '../util/audioUtil';
|
|||||||
import type { KeySignature } from '../core/KGProject';
|
import type { KeySignature } from '../core/KGProject';
|
||||||
import { ImportStemsCommand } from '../core/commands';
|
import { ImportStemsCommand } from '../core/commands';
|
||||||
import type { StemImportEntry } from '../core/commands';
|
import type { StemImportEntry } from '../core/commands';
|
||||||
import { showAlert } from './common/DialogProvider';
|
import { showAlert } from '../util/dialogUtil';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
|||||||
setGenStatus('polling');
|
setGenStatus('polling');
|
||||||
setGenHint('Generating clip...');
|
setGenHint('Generating clip...');
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
@@ -626,7 +626,7 @@ const FullSongTab: React.FC = () => {
|
|||||||
type ResultItem = { progress: number; stage: string; status: number };
|
type ResultItem = { progress: number; stage: string; status: number };
|
||||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
@@ -997,7 +997,7 @@ const SeparatorTab: React.FC = () => {
|
|||||||
|
|
||||||
let files: string[] = [];
|
let files: string[] = [];
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
@@ -1414,7 +1414,7 @@ const RemixTab: React.FC = () => {
|
|||||||
type ResultItem = { progress: number; stage: string; status: number };
|
type ResultItem = { progress: number; stage: string; status: number };
|
||||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
@@ -1932,7 +1932,7 @@ const RepaintTab: React.FC = () => {
|
|||||||
type ResultItem = { progress: number; stage: string; status: number };
|
type ResultItem = { progress: number; stage: string; status: number };
|
||||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||||
|
|
||||||
// eslint-disable-next-line no-constant-condition
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||||
FaPlay, FaPause, FaComments, FaSync,
|
FaPlay, FaPause, FaComments, FaSync,
|
||||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||||
FaCog, FaMagnet, FaCut
|
FaCog, FaMagnet, FaCut, FaCircle
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { KGProject, type KeySignature } from '../core/KGProject';
|
import { KGProject, type KeySignature } from '../core/KGProject';
|
||||||
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
import { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
||||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||||
@@ -31,7 +33,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
|
|||||||
import PianoIcon from './common/icons/PianoIcon';
|
import PianoIcon from './common/icons/PianoIcon';
|
||||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
import { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './common/DialogProvider';
|
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||||
|
|
||||||
const Toolbar: React.FC = () => {
|
const Toolbar: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -45,6 +47,7 @@ const Toolbar: React.FC = () => {
|
|||||||
isLooping, toggleLoop,
|
isLooping, toggleLoop,
|
||||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||||
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
||||||
|
isRecording, startRecording, stopRecording,
|
||||||
// Piano roll state/actions
|
// Piano roll state/actions
|
||||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||||
// Selection state
|
// Selection state
|
||||||
@@ -515,6 +518,11 @@ const Toolbar: React.FC = () => {
|
|||||||
console.log("Pause button clicked");
|
console.log("Pause button clicked");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (isRecording) {
|
||||||
|
await stopRecording();
|
||||||
|
setStatus("Recording stopped — notes committed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await stopPlaying();
|
await stopPlaying();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to stop playback:", error);
|
console.error("Failed to stop playback:", error);
|
||||||
@@ -860,6 +868,46 @@ const Toolbar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecordClick = async () => {
|
||||||
|
if (isRecording) {
|
||||||
|
await stopRecording();
|
||||||
|
setStatus("Recording stopped — notes committed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require an active or selected MIDI region
|
||||||
|
const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
|
||||||
|
if (!candidateId) {
|
||||||
|
await showAlert("Please open a MIDI region in the Piano Roll before starting 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) {
|
||||||
|
await showAlert("Please select a MIDI region before starting recording.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least one connected MIDI device
|
||||||
|
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
|
||||||
|
await showAlert("No MIDI device detected. Please connect a MIDI keyboard and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the piano roll is open showing the target region
|
||||||
|
if (!activeRegionId) {
|
||||||
|
setActiveRegionId(candidateId);
|
||||||
|
setShowPianoRoll(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await startRecording();
|
||||||
|
setStatus("Recording started...");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
@@ -943,6 +991,14 @@ const Toolbar: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
|
<button title="Pause" className="button-pause" onClick={handlePauseClick}><FaPause /></button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
title={isRecording ? "Stop Recording" : "Record"}
|
||||||
|
className={`tool-button ${isRecording ? 'active' : ''}`}
|
||||||
|
style={isRecording ? { color: 'red' } : undefined}
|
||||||
|
onClick={handleRecordClick}
|
||||||
|
>
|
||||||
|
<FaCircle />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Loop"
|
title="Loop"
|
||||||
className={`tool-button ${isLooping ? 'active' : ''}`}
|
className={`tool-button ${isLooping ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -1,60 +1,8 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import './DialogProvider.css';
|
import './DialogProvider.css';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
|
import { registerDialogFns } from '../../util/dialogUtil';
|
||||||
export interface ConfirmOptions {
|
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptOptions {
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeSigResult {
|
|
||||||
numerator: number;
|
|
||||||
denominator: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
|
|
||||||
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
|
|
||||||
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
|
||||||
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
|
||||||
|
|
||||||
export function showAlert(message: string): Promise<void> {
|
|
||||||
if (!_showAlertFn) {
|
|
||||||
window.alert(message);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return _showAlertFn(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
|
|
||||||
if (!_showConfirmFn) {
|
|
||||||
return Promise.resolve(window.confirm(message));
|
|
||||||
}
|
|
||||||
return _showConfirmFn(message, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
|
|
||||||
if (!_showPromptFn) {
|
|
||||||
return Promise.resolve(window.prompt(message, defaultValue));
|
|
||||||
}
|
|
||||||
return _showPromptFn(message, defaultValue, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
|
|
||||||
if (!_showTimeSigFn) {
|
|
||||||
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
|
|
||||||
if (!raw) return Promise.resolve(null);
|
|
||||||
const [n, d] = raw.split('/').map(Number);
|
|
||||||
if (!n || !d) return Promise.resolve(null);
|
|
||||||
return Promise.resolve({ numerator: n, denominator: d });
|
|
||||||
}
|
|
||||||
return _showTimeSigFn(message, defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogInfo {
|
interface DialogInfo {
|
||||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
|
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
|
||||||
@@ -129,10 +77,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
const registered = useRef(false);
|
const registered = useRef(false);
|
||||||
if (!registered.current) {
|
if (!registered.current) {
|
||||||
registered.current = true;
|
registered.current = true;
|
||||||
_showAlertFn = openAlert;
|
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
|
||||||
_showConfirmFn = openConfirm;
|
|
||||||
_showPromptFn = openPrompt;
|
|
||||||
_showTimeSigFn = openTimeSig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import './FileImportModal.css';
|
import './FileImportModal.css';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
import { showAlert } from './DialogProvider';
|
import { showAlert } from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface FileImportModalProps {
|
interface FileImportModalProps {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-ic
|
|||||||
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
|
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
|
||||||
import { isValidProjectName } from '../../util/projectNameUtil';
|
import { isValidProjectName } from '../../util/projectNameUtil';
|
||||||
import './OpenProjectModal.css';
|
import './OpenProjectModal.css';
|
||||||
import { showAlert, showConfirm, showPrompt } from './DialogProvider';
|
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface OpenProjectModalProps {
|
interface OpenProjectModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ export { default as Playhead } from './Playhead';
|
|||||||
export { default as FileImportModal } from './FileImportModal';
|
export { default as FileImportModal } from './FileImportModal';
|
||||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||||
export { default as OpenProjectModal } from './OpenProjectModal';
|
export { default as OpenProjectModal } from './OpenProjectModal';
|
||||||
export { default as DialogProvider, showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './DialogProvider';
|
export { default as DialogProvider } from './DialogProvider';
|
||||||
export type { ConfirmOptions, PromptOptions, TimeSigResult } from './DialogProvider';
|
export { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../../util/dialogUtil';
|
||||||
|
export type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
|
||||||
@@ -255,6 +255,14 @@
|
|||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.piano-grid-recording-note {
|
||||||
|
position: absolute;
|
||||||
|
background-color: rgba(255, 60, 60, 0.35);
|
||||||
|
border: 1px solid rgba(255, 60, 60, 0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 5px;
|
right: 5px;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { ConfigManager } from '../../core/config/ConfigManager';
|
|||||||
import { beatsToBar } from '../../util/midiUtil';
|
import { beatsToBar } from '../../util/midiUtil';
|
||||||
import { UpdateRegionCommand } from '../../core/commands';
|
import { UpdateRegionCommand } from '../../core/commands';
|
||||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||||
import { showAlert, showPrompt } from '../common/DialogProvider';
|
import { showAlert, showPrompt } from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface PianoRollProps {
|
interface PianoRollProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
|||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
import { KGTrack } from '../../core/track/KGTrack';
|
import { KGTrack } from '../../core/track/KGTrack';
|
||||||
import { KGCore } from '../../core/KGCore';
|
import { KGCore } from '../../core/KGCore';
|
||||||
|
import { useProjectStore } from '../../stores/projectStore';
|
||||||
import PianoNote from './PianoNote';
|
import PianoNote from './PianoNote';
|
||||||
import PianoKeys from './PianoKeys';
|
import PianoKeys from './PianoKeys';
|
||||||
import PianoGridHeader from './PianoGridHeader';
|
import PianoGridHeader from './PianoGridHeader';
|
||||||
@@ -43,7 +44,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// Get KGCore instance
|
// Get KGCore instance
|
||||||
const core = KGCore.instance();
|
const core = KGCore.instance();
|
||||||
|
|
||||||
|
// Recording state
|
||||||
|
const isRecording = useProjectStore(s => s.isRecording);
|
||||||
|
const recordingNotes = useProjectStore(s => s.recordingNotes);
|
||||||
|
|
||||||
// Use the note operations hook for resize and drag functionality
|
// Use the note operations hook for resize and drag functionality
|
||||||
const {
|
const {
|
||||||
resizingNoteId,
|
resizingNoteId,
|
||||||
@@ -216,8 +221,26 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
});
|
});
|
||||||
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
|
}, [activeRegion, noteUpdateCounter, resizingNoteId, draggingNoteId, tempNoteStyles, selectedNoteIds, selectionBoxRender, tracks]);
|
||||||
|
|
||||||
|
const recordingNoteOverlays = useMemo(() => {
|
||||||
|
if (!isRecording || !activeRegion || recordingNotes.length === 0) return null;
|
||||||
|
const beatWidth = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')) || 40;
|
||||||
|
const noteHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--region-piano-key-height')) || 20;
|
||||||
|
return recordingNotes.map((note, index) => (
|
||||||
|
<div
|
||||||
|
key={`recording-note-${index}`}
|
||||||
|
className="piano-grid-recording-note"
|
||||||
|
style={{
|
||||||
|
left: note.startBeat * beatWidth,
|
||||||
|
top: (107 - note.pitch) * noteHeight,
|
||||||
|
width: Math.max((note.endBeat - note.startBeat) * beatWidth, 4),
|
||||||
|
height: noteHeight,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}, [isRecording, recordingNotes, activeRegion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="piano-roll-content"
|
className="piano-roll-content"
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
>
|
>
|
||||||
@@ -239,6 +262,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
|||||||
chordGuide={chordGuide}
|
chordGuide={chordGuide}
|
||||||
>
|
>
|
||||||
{memoizedNotes}
|
{memoizedNotes}
|
||||||
|
{recordingNoteOverlays}
|
||||||
</PianoGrid>
|
</PianoGrid>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
|
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
|
||||||
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
|
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
|
||||||
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
||||||
|
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
||||||
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
||||||
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
||||||
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
||||||
|
const [recordingOffsetValidationErrors, setRecordingOffsetValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
const configManager = ConfigManager.instance();
|
const configManager = ConfigManager.instance();
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
|
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
|
||||||
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
|
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
|
||||||
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
setPlaybackDelay(((playbackDelaySeconds * 1000).toFixed(0)));
|
||||||
|
const recordingOffsetSeconds = (configManager.get('audio.recording_offset') as number) ?? 0;
|
||||||
|
setRecordingOffset(((recordingOffsetSeconds * 1000).toFixed(0)));
|
||||||
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
|
setEnableAudioCapture((configManager.get('audio.enable_audio_capture_for_screen_sharing') as boolean) ?? false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +106,28 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecordingOffsetChange = async (value: string) => {
|
||||||
|
const numValueMs = value === '' ? 0 : parseFloat(value);
|
||||||
|
const numValueSeconds = numValueMs / 1000;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (isNaN(numValueMs)) {
|
||||||
|
errors.push('MIDI input latency must be a valid number');
|
||||||
|
} else if (numValueSeconds < 0) {
|
||||||
|
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
|
||||||
|
} else if (numValueSeconds > 0.5) {
|
||||||
|
errors.push('MIDI input latency must be between 0 and 0.5 seconds (0-500ms)');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecordingOffsetValidationErrors(errors);
|
||||||
|
|
||||||
|
if (errors.length === 0) {
|
||||||
|
setRecordingOffset(value);
|
||||||
|
await configManager.set('audio.recording_offset', numValueSeconds);
|
||||||
|
console.log(`MIDI input latency changed to: ${numValueSeconds}s (${numValueMs}ms)`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnableAudioCaptureChange = async (value: string) => {
|
const handleEnableAudioCaptureChange = async (value: string) => {
|
||||||
const boolValue = value === 'yes';
|
const boolValue = value === 'yes';
|
||||||
setEnableAudioCapture(boolValue);
|
setEnableAudioCapture(boolValue);
|
||||||
@@ -212,6 +238,33 @@ const BehaviorSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-item">
|
||||||
|
<label className="settings-label">
|
||||||
|
MIDI Input Latency (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="settings-select"
|
||||||
|
value={recordingOffset}
|
||||||
|
onChange={(e) => handleRecordingOffsetChange(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
max="500"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
{recordingOffsetValidationErrors.length > 0 && (
|
||||||
|
<div className="settings-validation-errors">
|
||||||
|
{recordingOffsetValidationErrors.map((error, index) => (
|
||||||
|
<div key={index} className="settings-validation-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
Timing correction for MIDI recording (0-500ms). If recorded notes appear slightly late compared to where you intended to play them, increase this value to match your MIDI device's input latency. Each note's position is shifted back by this amount when committed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-item">
|
<div className="settings-item">
|
||||||
<label className="settings-label">
|
<label className="settings-label">
|
||||||
Capture Audio for Screen Sharing
|
Capture Audio for Screen Sharing
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||||
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
|
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
|
||||||
import { KGCore } from '../../../core/KGCore';
|
import { KGCore } from '../../../core/KGCore';
|
||||||
import { showAlert } from '../../common/DialogProvider';
|
import { showAlert } from '../../../util/dialogUtil';
|
||||||
|
|
||||||
const ChordGuideSettings: React.FC = () => {
|
const ChordGuideSettings: React.FC = () => {
|
||||||
const [chordDefinition, setChordDefinition] = useState<string>('');
|
const [chordDefinition, setChordDefinition] = useState<string>('');
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
.track-info {
|
.track-info {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
padding: 15px;
|
padding: 15px 5px 15px 15px;
|
||||||
background-color: #2d2d2d;
|
background-color: #2d2d2d;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -155,6 +155,34 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.volume-slider .volume-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #aaa;
|
||||||
|
min-width: 22px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider .volume-label-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider .volume-label-clickable:hover {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider .volume-label-input {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 0 2px;
|
||||||
|
width: 36px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.volume-slider input[type="range"] {
|
.volume-slider input[type="range"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
|||||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||||
import { generateNewRegionName } from '../../util/miscUtil';
|
import { generateNewRegionName } from '../../util/miscUtil';
|
||||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||||
import { showAlert } from '../common/DialogProvider';
|
import { showAlert } from '../../util/dialogUtil';
|
||||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,36 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
|||||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider';
|
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||||
|
|
||||||
|
const UNITY_POS = 750;
|
||||||
|
const SLIDER_MAX = 1000;
|
||||||
|
|
||||||
|
function sliderToDb(pos: number): number {
|
||||||
|
const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
|
const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB;
|
||||||
|
if (pos <= 0) return MIN;
|
||||||
|
if (pos >= SLIDER_MAX) return MAX;
|
||||||
|
if (pos <= UNITY_POS) {
|
||||||
|
const t = pos / UNITY_POS;
|
||||||
|
return MIN * (1 - t * t);
|
||||||
|
}
|
||||||
|
return MAX * (pos - UNITY_POS) / (SLIDER_MAX - UNITY_POS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbToSlider(db: number): number {
|
||||||
|
const MIN = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
|
const MAX = AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB;
|
||||||
|
if (db <= MIN) return 0;
|
||||||
|
if (db >= MAX) return SLIDER_MAX;
|
||||||
|
if (db <= 0) return Math.round(Math.sqrt(1 - db / MIN) * UNITY_POS);
|
||||||
|
return Math.round(UNITY_POS + (db / MAX) * (SLIDER_MAX - UNITY_POS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDb(db: number): string {
|
||||||
|
if (db <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB) return '−∞';
|
||||||
|
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
|
||||||
|
}
|
||||||
interface TrackInfoItemProps {
|
interface TrackInfoItemProps {
|
||||||
track: KGTrack;
|
track: KGTrack;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -56,6 +85,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const suppressDragRef = useRef(false);
|
const suppressDragRef = useRef(false);
|
||||||
const [volume, setVolume] = useState(track.getVolume());
|
const [volume, setVolume] = useState(track.getVolume());
|
||||||
|
const [isEditingVolume, setIsEditingVolume] = useState(false);
|
||||||
|
const [volumeInputText, setVolumeInputText] = useState('');
|
||||||
|
const volumeInputRef = useRef<HTMLInputElement>(null);
|
||||||
// Local flag to track slider interaction; not used for rendering
|
// Local flag to track slider interaction; not used for rendering
|
||||||
const isAdjustingVolumeRef = useRef(false);
|
const isAdjustingVolumeRef = useRef(false);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
@@ -129,11 +161,10 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
|
|
||||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const next = Number(e.target.value) / 100;
|
const next = sliderToDb(Number(e.target.value));
|
||||||
isAdjustingVolumeRef.current = true;
|
isAdjustingVolumeRef.current = true;
|
||||||
setVolume(next);
|
setVolume(next);
|
||||||
try {
|
try {
|
||||||
// Live preview: update audio only while sliding
|
|
||||||
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
|
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update live volume:', err);
|
console.error('Failed to update live volume:', err);
|
||||||
@@ -167,6 +198,43 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVolumeLabelClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const displayText = volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB
|
||||||
|
? '-60'
|
||||||
|
: volume.toFixed(1);
|
||||||
|
setVolumeInputText(displayText);
|
||||||
|
setIsEditingVolume(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
volumeInputRef.current?.select();
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitVolumeLabelInput = () => {
|
||||||
|
setIsEditingVolume(false);
|
||||||
|
const parsed = parseFloat(volumeInputText);
|
||||||
|
if (isNaN(parsed)) return;
|
||||||
|
const clamped = Math.max(
|
||||||
|
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
|
||||||
|
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, parsed)
|
||||||
|
);
|
||||||
|
setVolume(clamped);
|
||||||
|
try {
|
||||||
|
useProjectStore.getState().updateTrackProperties(track.getId(), { volume: clamped });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set volume from label input:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeLabelKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitVolumeLabelInput();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsEditingVolume(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleToggleMute = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const next = !muted;
|
const next = !muted;
|
||||||
@@ -302,8 +370,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="1000"
|
||||||
value={Math.round(volume * 100)}
|
step="1"
|
||||||
|
value={dbToSlider(volume)}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
|
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
|
||||||
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
|
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
|
||||||
@@ -314,12 +383,32 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="reset-volume"
|
className="reset-volume"
|
||||||
title="Reset volume"
|
title="Reset to 0 dB"
|
||||||
aria-label="Reset volume"
|
aria-label="Reset volume to 0 dB"
|
||||||
onClick={handleResetVolume}
|
onClick={handleResetVolume}
|
||||||
>
|
>
|
||||||
↺
|
↺
|
||||||
</button>
|
</button>
|
||||||
|
{isEditingVolume ? (
|
||||||
|
<input
|
||||||
|
ref={volumeInputRef}
|
||||||
|
className="volume-label volume-label-input"
|
||||||
|
type="text"
|
||||||
|
value={volumeInputText}
|
||||||
|
onChange={(e) => setVolumeInputText(e.target.value)}
|
||||||
|
onKeyDown={handleVolumeLabelKeyDown}
|
||||||
|
onBlur={commitVolumeLabelInput}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="volume-label volume-label-clickable"
|
||||||
|
title="Click to enter dB value"
|
||||||
|
onClick={handleVolumeLabelClick}
|
||||||
|
>
|
||||||
|
{formatDb(volume)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export const KEY_SIGNATURE_MAP = {
|
|||||||
|
|
||||||
export const AUDIO_INTERFACE_CONSTANTS = {
|
export const AUDIO_INTERFACE_CONSTANTS = {
|
||||||
DEFAULT_MASTER_VOLUME: 0.8,
|
DEFAULT_MASTER_VOLUME: 0.8,
|
||||||
DEFAULT_TRACK_VOLUME: 0.8,
|
DEFAULT_TRACK_VOLUME: 0, // 0 dB (unity gain)
|
||||||
|
MIN_TRACK_VOLUME_DB: -60, // practical floor; displayed as −∞
|
||||||
|
MAX_TRACK_VOLUME_DB: 12,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SAMPLER_CONSTANTS = {
|
export const SAMPLER_CONSTANTS = {
|
||||||
|
|||||||
+14
-6
@@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion';
|
|||||||
import { generateUniqueId } from '../util/miscUtil';
|
import { generateUniqueId } from '../util/miscUtil';
|
||||||
import { KGCommand, KGCommandHistory } from './commands';
|
import { KGCommand, KGCommandHistory } from './commands';
|
||||||
|
|
||||||
|
interface PlaybackStartOptions {
|
||||||
|
preserveLoopPreroll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGCore - Main application class for the DAW
|
* KGCore - Main application class for the DAW
|
||||||
* Implements the singleton pattern for global access
|
* Implements the singleton pattern for global access
|
||||||
@@ -231,7 +235,7 @@ export class KGCore {
|
|||||||
return this.isPlaying;
|
return this.isPlaying;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async preparePlay(): Promise<void> {
|
public async preparePlay(options?: PlaybackStartOptions): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const audioInterface = KGAudioInterface.instance();
|
const audioInterface = KGAudioInterface.instance();
|
||||||
|
|
||||||
@@ -239,7 +243,9 @@ export class KGCore {
|
|||||||
await audioInterface.startAudioContext();
|
await audioInterface.startAudioContext();
|
||||||
|
|
||||||
// Prepare playback with current project and playhead position
|
// Prepare playback with current project and playhead position
|
||||||
audioInterface.preparePlayback(this.currentProject, this.playheadPosition);
|
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
|
||||||
|
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
// Sync BPM and transport settings
|
// Sync BPM and transport settings
|
||||||
audioInterface.setBpm(this.currentProject.getBpm());
|
audioInterface.setBpm(this.currentProject.getBpm());
|
||||||
@@ -301,7 +307,7 @@ export class KGCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// High-level playback control methods
|
// High-level playback control methods
|
||||||
public async startPlaying(): Promise<void> {
|
public async startPlaying(options?: PlaybackStartOptions): Promise<void> {
|
||||||
// Handle loop mode initialization
|
// Handle loop mode initialization
|
||||||
if (this.currentProject.getIsLooping()) {
|
if (this.currentProject.getIsLooping()) {
|
||||||
const [startBar, endBar] = this.currentProject.getLoopingRange();
|
const [startBar, endBar] = this.currentProject.getLoopingRange();
|
||||||
@@ -319,11 +325,13 @@ export class KGCore {
|
|||||||
const updatedRange = this.currentProject.getLoopingRange();
|
const updatedRange = this.currentProject.getLoopingRange();
|
||||||
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||||
const loopStartBeats = updatedRange[0] * beatsPerBar;
|
const loopStartBeats = updatedRange[0] * beatsPerBar;
|
||||||
this.setPlayheadPosition(loopStartBeats);
|
if (!options?.preserveLoopPreroll) {
|
||||||
|
this.setPlayheadPosition(loopStartBeats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare playback first
|
// Prepare playback first
|
||||||
await this.preparePlay();
|
await this.preparePlay(options);
|
||||||
|
|
||||||
// Start playing (non-blocking)
|
// Start playing (non-blocking)
|
||||||
this.play(); // Don't await this
|
this.play(); // Don't await this
|
||||||
@@ -645,4 +653,4 @@ export class KGCore {
|
|||||||
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
|
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
|
||||||
return this.commandHistory.getHistoryStats();
|
return this.commandHistory.getHistoryStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-91
@@ -513,8 +513,7 @@ export class KGDebugger {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||||
} catch {
|
} catch { /* setting cursor position can fail in read-only or special inputs */ }
|
||||||
}
|
|
||||||
|
|
||||||
textarea.scrollTop = textarea.scrollHeight;
|
textarea.scrollTop = textarea.scrollHeight;
|
||||||
|
|
||||||
@@ -583,8 +582,7 @@ export class KGDebugger {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||||
} catch {
|
} catch { /* setting cursor position can fail in read-only or special inputs */ }
|
||||||
}
|
|
||||||
|
|
||||||
textarea.scrollTop = textarea.scrollHeight;
|
textarea.scrollTop = textarea.scrollHeight;
|
||||||
|
|
||||||
@@ -673,212 +671,212 @@ export class KGDebugger {
|
|||||||
* await KGDebugger.opfs('cat project.json')
|
* await KGDebugger.opfs('cat project.json')
|
||||||
*/
|
*/
|
||||||
public async opfs(command: string): Promise<void> {
|
public async opfs(command: string): Promise<void> {
|
||||||
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
||||||
const cmd = parts[0]
|
const cmd = parts[0];
|
||||||
// Strip surrounding quotes from arguments
|
// 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 {
|
try {
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'pwd':
|
case 'pwd':
|
||||||
console.log('/' + this.opfsCwd.join('/'))
|
console.log('/' + this.opfsCwd.join('/'));
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'ls':
|
case 'ls':
|
||||||
await this.opfsLs()
|
await this.opfsLs();
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'cd':
|
case 'cd':
|
||||||
await this.opfsCd(arg)
|
await this.opfsCd(arg);
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'cat':
|
case 'cat':
|
||||||
await this.opfsCat(arg)
|
await this.opfsCat(arg);
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'dl':
|
case 'dl':
|
||||||
await this.opfsDl(arg)
|
await this.opfsDl(arg);
|
||||||
break
|
break;
|
||||||
|
|
||||||
case 'rm':
|
case 'rm':
|
||||||
await this.opfsRm(arg)
|
await this.opfsRm(arg);
|
||||||
break
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log(`opfs: command not found: ${cmd}`)
|
console.log(`opfs: command not found: ${cmd}`);
|
||||||
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>')
|
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`opfs: ${error}`)
|
console.error(`opfs: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
|
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
|
||||||
let dir = await navigator.storage.getDirectory()
|
let dir = await navigator.storage.getDirectory();
|
||||||
for (const segment of this.opfsCwd) {
|
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> {
|
private async opfsLs(): Promise<void> {
|
||||||
const dir = await this.opfsResolveCwd()
|
const dir = await this.opfsResolveCwd();
|
||||||
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
|
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [];
|
||||||
|
|
||||||
for await (const entry of dir.values()) {
|
for await (const entry of dir.values()) {
|
||||||
if (entry.kind === 'file') {
|
if (entry.kind === 'file') {
|
||||||
const fileHandle = entry as FileSystemFileHandle
|
const fileHandle = entry as FileSystemFileHandle;
|
||||||
const file = await fileHandle.getFile()
|
const file = await fileHandle.getFile();
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
|
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
entries.push({
|
entries.push({
|
||||||
kind: 'dir',
|
kind: 'dir',
|
||||||
name: entry.name + '/',
|
name: entry.name + '/',
|
||||||
size: 0,
|
size: 0,
|
||||||
modified: '-',
|
modified: '-',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: directories first, then files, alphabetically within each group
|
// Sort: directories first, then files, alphabetically within each group
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name)
|
return a.name.localeCompare(b.name);
|
||||||
})
|
});
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
console.log('(empty directory)')
|
console.log('(empty directory)');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print header
|
// Print header
|
||||||
const cwdPath = '/' + this.opfsCwd.join('/')
|
const cwdPath = '/' + this.opfsCwd.join('/');
|
||||||
console.log(`total ${entries.length} (${cwdPath})`)
|
console.log(`total ${entries.length} (${cwdPath})`);
|
||||||
|
|
||||||
// Format like ls -lla
|
// 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) {
|
for (const e of entries) {
|
||||||
const typeChar = e.kind === 'dir' ? 'd' : '-'
|
const typeChar = e.kind === 'dir' ? 'd' : '-';
|
||||||
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
|
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--';
|
||||||
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
|
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen);
|
||||||
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
|
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async opfsCd(path: string): Promise<void> {
|
private async opfsCd(path: string): Promise<void> {
|
||||||
if (!path || path === '') {
|
if (!path || path === '') {
|
||||||
// cd with no args goes to root
|
// cd with no args goes to root
|
||||||
this.opfsCwd = []
|
this.opfsCwd = [];
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let segments: string[]
|
let segments: string[];
|
||||||
|
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
this.opfsCwd = []
|
this.opfsCwd = [];
|
||||||
return
|
return;
|
||||||
} else if (path.startsWith('/')) {
|
} else if (path.startsWith('/')) {
|
||||||
// Absolute path
|
// Absolute path
|
||||||
segments = path.split('/').filter(Boolean)
|
segments = path.split('/').filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
// Relative path
|
// Relative path
|
||||||
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
|
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve . and ..
|
// Resolve . and ..
|
||||||
const resolved: string[] = []
|
const resolved: string[] = [];
|
||||||
for (const seg of segments) {
|
for (const seg of segments) {
|
||||||
if (seg === '.') continue
|
if (seg === '.') continue;
|
||||||
if (seg === '..') {
|
if (seg === '..') {
|
||||||
resolved.pop()
|
resolved.pop();
|
||||||
} else {
|
} else {
|
||||||
resolved.push(seg)
|
resolved.push(seg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the path exists
|
// Verify the path exists
|
||||||
let dir = await navigator.storage.getDirectory()
|
let dir = await navigator.storage.getDirectory();
|
||||||
for (const seg of resolved) {
|
for (const seg of resolved) {
|
||||||
try {
|
try {
|
||||||
dir = await dir.getDirectoryHandle(seg)
|
dir = await dir.getDirectoryHandle(seg);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`opfs: cd: no such directory: ${path}`)
|
console.error(`opfs: cd: no such directory: ${path}`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.opfsCwd = resolved
|
this.opfsCwd = resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async opfsCat(fileName: string): Promise<void> {
|
private async opfsCat(fileName: string): Promise<void> {
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
console.error('opfs: cat: missing file name')
|
console.error('opfs: cat: missing file name');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = await this.opfsResolveCwd()
|
const dir = await this.opfsResolveCwd();
|
||||||
try {
|
try {
|
||||||
const fileHandle = await dir.getFileHandle(fileName)
|
const fileHandle = await dir.getFileHandle(fileName);
|
||||||
const file = await fileHandle.getFile()
|
const file = await fileHandle.getFile();
|
||||||
const text = await file.text()
|
const text = await file.text();
|
||||||
|
|
||||||
// Pretty-print JSON files
|
// Pretty-print JSON files
|
||||||
if (fileName.endsWith('.json')) {
|
if (fileName.endsWith('.json')) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text)
|
const parsed = JSON.parse(text);
|
||||||
console.log(JSON.stringify(parsed, null, 2))
|
console.log(JSON.stringify(parsed, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
console.log(text)
|
console.log(text);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(text)
|
console.log(text);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`opfs: cat: ${fileName}: No such file`)
|
console.error(`opfs: cat: ${fileName}: No such file`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async opfsDl(fileName: string): Promise<void> {
|
private async opfsDl(fileName: string): Promise<void> {
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
console.error('opfs: dl: missing file name')
|
console.error('opfs: dl: missing file name');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = await this.opfsResolveCwd()
|
const dir = await this.opfsResolveCwd();
|
||||||
try {
|
try {
|
||||||
const fileHandle = await dir.getFileHandle(fileName)
|
const fileHandle = await dir.getFileHandle(fileName);
|
||||||
const file = await fileHandle.getFile()
|
const file = await fileHandle.getFile();
|
||||||
const url = URL.createObjectURL(file)
|
const url = URL.createObjectURL(file);
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a');
|
||||||
a.href = url
|
a.href = url;
|
||||||
a.download = fileName
|
a.download = fileName;
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a);
|
||||||
a.click()
|
a.click();
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url);
|
||||||
console.log(`downloaded: ${fileName} (${file.size} bytes)`)
|
console.log(`downloaded: ${fileName} (${file.size} bytes)`);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`opfs: dl: ${fileName}: No such file`)
|
console.error(`opfs: dl: ${fileName}: No such file`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async opfsRm(name: string): Promise<void> {
|
private async opfsRm(name: string): Promise<void> {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
console.error('opfs: rm: missing file or directory name')
|
console.error('opfs: rm: missing file or directory name');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = await this.opfsResolveCwd()
|
const dir = await this.opfsResolveCwd();
|
||||||
try {
|
try {
|
||||||
await dir.removeEntry(name, { recursive: true })
|
await dir.removeEntry(name, { recursive: true });
|
||||||
console.log(`removed: ${name}`)
|
console.log(`removed: ${name}`);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`opfs: rm: ${name}: No such file or directory`)
|
console.error(`opfs: rm: ${name}: No such file or directory`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export class KGProject {
|
|||||||
@WithDefault(0)
|
@WithDefault(0)
|
||||||
private projectStructureVersion: number = 0;
|
private projectStructureVersion: number = 0;
|
||||||
|
|
||||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
|
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 7;
|
||||||
|
|
||||||
@Expose()
|
@Expose()
|
||||||
@Type(() => KGTrack, {
|
@Type(() => KGTrack, {
|
||||||
|
|||||||
@@ -283,9 +283,8 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
private updateSamplerVolume(): void {
|
private updateSamplerVolume(): void {
|
||||||
try {
|
try {
|
||||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||||
this.sampler.volume.value = volumeDb;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||||
}
|
}
|
||||||
@@ -297,14 +296,8 @@ export class KGAudioBus {
|
|||||||
*/
|
*/
|
||||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||||
try {
|
try {
|
||||||
let effectiveVolume = this.volume;
|
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
if (this.muted) {
|
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||||
effectiveVolume = 0;
|
|
||||||
} else if (hasSoloedTracks && !this.solo) {
|
|
||||||
effectiveVolume = 0;
|
|
||||||
}
|
|
||||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
|
||||||
this.sampler.volume.value = volumeDb;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createMockProject } from '../../test/utils/mock-data';
|
||||||
|
import { MockTransport } from '../../test/mocks/tone';
|
||||||
|
|
||||||
|
vi.mock('tone', async () => {
|
||||||
|
const { ToneMock } = await import('../../test/mocks/tone');
|
||||||
|
return ToneMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../KGCore', () => ({
|
||||||
|
KGCore: {
|
||||||
|
instance: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config/ConfigManager', () => ({
|
||||||
|
ConfigManager: {
|
||||||
|
instance: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { KGCore } from '../KGCore';
|
||||||
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
|
import { KGAudioInterface } from './KGAudioInterface';
|
||||||
|
|
||||||
|
describe('KGAudioInterface preroll playback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
MockTransport.position = 0;
|
||||||
|
MockTransport.start.mockClear();
|
||||||
|
MockTransport.stop.mockClear();
|
||||||
|
MockTransport.clear.mockClear();
|
||||||
|
MockTransport.schedule.mockClear();
|
||||||
|
MockTransport.bpm.value = 120;
|
||||||
|
|
||||||
|
const project = createMockProject({
|
||||||
|
bpm: 120,
|
||||||
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
|
tracks: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(KGCore.instance).mockReturnValue({
|
||||||
|
getCurrentProject: () => project,
|
||||||
|
} as unknown as KGCore);
|
||||||
|
|
||||||
|
vi.mocked(ConfigManager.instance).mockReturnValue({
|
||||||
|
get: (key: string) => {
|
||||||
|
if (key === 'audio.playback_delay') return 0.2;
|
||||||
|
if (key === 'audio.lookahead_time') return 0.05;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
} as unknown as ConfigManager)
|
||||||
|
|
||||||
|
;(KGAudioInterface as unknown as { _instance: KGAudioInterface | null })._instance = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses virtual negative beats until the delayed transport start reaches beat 0', () => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const audio = KGAudioInterface.instance()
|
||||||
|
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
|
||||||
|
;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
|
||||||
|
|
||||||
|
const metronomeStart = vi.spyOn((audio as unknown as { metronome: { start: (...args: unknown[]) => void } }).metronome, 'start');
|
||||||
|
audio.setMetronomeEnabled(true);
|
||||||
|
|
||||||
|
audio.preparePlayback(project, -2);
|
||||||
|
audio.startPlayback();
|
||||||
|
|
||||||
|
expect(MockTransport.position).toBe(0);
|
||||||
|
expect(MockTransport.start).not.toHaveBeenCalled();
|
||||||
|
expect(metronomeStart).toHaveBeenCalledWith(-2, 4, 0.2);
|
||||||
|
expect(audio.getTransportPosition()).toBeCloseTo(-2, 2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(audio.getTransportPosition()).toBeCloseTo(-1, 1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(MockTransport.start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audio.getTransportPosition()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels the delayed transport start when playback stops during preroll', () => {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const audio = KGAudioInterface.instance()
|
||||||
|
;(audio as unknown as { isInitialized: boolean }).isInitialized = true
|
||||||
|
;(audio as unknown as { isAudioContextStarted: boolean }).isAudioContextStarted = true;
|
||||||
|
|
||||||
|
audio.preparePlayback(project, -2);
|
||||||
|
audio.startPlayback();
|
||||||
|
audio.stopPlayback();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(MockTransport.start).not.toHaveBeenCalled();
|
||||||
|
expect(MockTransport.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(audio.getTransportPosition()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a first-pass start before the loop start when explicitly requested', () => {
|
||||||
|
const project = createMockProject({
|
||||||
|
bpm: 120,
|
||||||
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
|
tracks: [],
|
||||||
|
});
|
||||||
|
project.setIsLooping(true);
|
||||||
|
project.setLoopingRange([4, 7]);
|
||||||
|
|
||||||
|
const audio = KGAudioInterface.instance();
|
||||||
|
audio.preparePlayback(project, 12, { allowStartBeforeLoopStart: true });
|
||||||
|
|
||||||
|
expect(MockTransport.position).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,10 @@ import { KGCore } from '../KGCore';
|
|||||||
import { ConfigManager } from '../config/ConfigManager';
|
import { ConfigManager } from '../config/ConfigManager';
|
||||||
import { KGMetronome } from './KGMetronome';
|
import { KGMetronome } from './KGMetronome';
|
||||||
|
|
||||||
|
interface PreparePlaybackOptions {
|
||||||
|
allowStartBeforeLoopStart?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KGAudioInterface - Audio engine interface for the DAW
|
* KGAudioInterface - Audio engine interface for the DAW
|
||||||
* Implements the singleton pattern for global audio management
|
* Implements the singleton pattern for global audio management
|
||||||
@@ -42,6 +46,10 @@ export class KGAudioInterface {
|
|||||||
private isPlaying: boolean = false;
|
private isPlaying: boolean = false;
|
||||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||||
private scheduledEvents: Set<number> = new Set(); // Tone event IDs
|
private scheduledEvents: Set<number> = new Set(); // Tone event IDs
|
||||||
|
private delayedTransportStartTimeoutId: number | null = null;
|
||||||
|
private delayedTransportStartSeconds: number = 0;
|
||||||
|
private virtualPrerollStartBeat: number | null = null;
|
||||||
|
private virtualPrerollStartAudioTime: number | null = null;
|
||||||
|
|
||||||
// Master volume control
|
// Master volume control
|
||||||
private masterGain: Tone.Gain | null = null;
|
private masterGain: Tone.Gain | null = null;
|
||||||
@@ -387,9 +395,10 @@ export class KGAudioInterface {
|
|||||||
/**
|
/**
|
||||||
* Prepare playback by scheduling all MIDI events
|
* Prepare playback by scheduling all MIDI events
|
||||||
*/
|
*/
|
||||||
public preparePlayback(project: KGProject, startPosition: number): void {
|
public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void {
|
||||||
// Clear any existing scheduled events
|
// Clear any existing scheduled events
|
||||||
this.clearScheduledEvents();
|
this.clearScheduledEvents();
|
||||||
|
this.clearDelayedTransportStart();
|
||||||
|
|
||||||
console.log("Preparing playback");
|
console.log("Preparing playback");
|
||||||
|
|
||||||
@@ -432,7 +441,7 @@ export class KGAudioInterface {
|
|||||||
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
||||||
|
|
||||||
// Adjust start position to loop start if before loop range
|
// Adjust start position to loop start if before loop range
|
||||||
if (startPosition < scheduleStartBeat) {
|
if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
|
||||||
startPosition = scheduleStartBeat;
|
startPosition = scheduleStartBeat;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -440,8 +449,18 @@ export class KGAudioInterface {
|
|||||||
console.log("Loop mode disabled");
|
console.log("Loop mode disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startPosition < 0) {
|
||||||
|
this.delayedTransportStartSeconds = Math.abs(startPosition) * secondsPerBeat;
|
||||||
|
this.virtualPrerollStartBeat = startPosition;
|
||||||
|
this.virtualPrerollStartAudioTime = null;
|
||||||
|
} else {
|
||||||
|
this.delayedTransportStartSeconds = 0;
|
||||||
|
this.virtualPrerollStartBeat = null;
|
||||||
|
this.virtualPrerollStartAudioTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Set transport position (convert beats to Tone.js format)
|
// Set transport position (convert beats to Tone.js format)
|
||||||
this.setTransportPosition(startPosition);
|
this.setTransportPosition(Math.max(0, startPosition));
|
||||||
|
|
||||||
// Start metronome if enabled
|
// Start metronome if enabled
|
||||||
if (this.isMetronomeEnabled) {
|
if (this.isMetronomeEnabled) {
|
||||||
@@ -646,8 +665,19 @@ export class KGAudioInterface {
|
|||||||
if (!this.isAudioContextStarted) {
|
if (!this.isAudioContextStarted) {
|
||||||
throw new Error('Audio context not started');
|
throw new Error('Audio context not started');
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone.Transport.start();
|
if (this.delayedTransportStartSeconds > 0 && this.virtualPrerollStartBeat !== null) {
|
||||||
|
this.virtualPrerollStartAudioTime = Tone.now();
|
||||||
|
this.delayedTransportStartTimeoutId = Tone.getContext().setTimeout(() => {
|
||||||
|
this.delayedTransportStartTimeoutId = null;
|
||||||
|
this.virtualPrerollStartBeat = null;
|
||||||
|
this.virtualPrerollStartAudioTime = null;
|
||||||
|
Tone.Transport.start();
|
||||||
|
}, this.delayedTransportStartSeconds);
|
||||||
|
} else {
|
||||||
|
Tone.Transport.start();
|
||||||
|
}
|
||||||
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
||||||
console.log('Audio playback started');
|
console.log('Audio playback started');
|
||||||
@@ -662,6 +692,7 @@ export class KGAudioInterface {
|
|||||||
*/
|
*/
|
||||||
public stopPlayback(): void {
|
public stopPlayback(): void {
|
||||||
try {
|
try {
|
||||||
|
this.clearDelayedTransportStart();
|
||||||
Tone.Transport.stop();
|
Tone.Transport.stop();
|
||||||
this.metronome.stop();
|
this.metronome.stop();
|
||||||
|
|
||||||
@@ -789,7 +820,8 @@ export class KGAudioInterface {
|
|||||||
public setTransportPosition(position: number): void {
|
public setTransportPosition(position: number): void {
|
||||||
try {
|
try {
|
||||||
// Convert beats to Tone.js time format
|
// Convert beats to Tone.js time format
|
||||||
const toneTime = this.beatsToToneTime(position);
|
const safePosition = Math.max(0, position);
|
||||||
|
const toneTime = this.beatsToToneTime(safePosition);
|
||||||
Tone.Transport.position = toneTime;
|
Tone.Transport.position = toneTime;
|
||||||
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -802,6 +834,14 @@ export class KGAudioInterface {
|
|||||||
*/
|
*/
|
||||||
public getTransportPosition(): number {
|
public getTransportPosition(): number {
|
||||||
try {
|
try {
|
||||||
|
if (this.virtualPrerollStartBeat !== null && this.virtualPrerollStartAudioTime !== null) {
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
const secondsPerBeat = 60 / project.getBpm();
|
||||||
|
const elapsedSeconds = Math.max(0, Tone.now() - this.virtualPrerollStartAudioTime);
|
||||||
|
const elapsedBeats = elapsedSeconds / secondsPerBeat;
|
||||||
|
return Math.min(0, this.virtualPrerollStartBeat + elapsedBeats);
|
||||||
|
}
|
||||||
|
|
||||||
const position = Tone.Transport.position;
|
const position = Tone.Transport.position;
|
||||||
return this.toneTimeToBeats(position);
|
return this.toneTimeToBeats(position);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -973,6 +1013,17 @@ export class KGAudioInterface {
|
|||||||
return this.captureStream;
|
return this.captureStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearDelayedTransportStart(): void {
|
||||||
|
if (this.delayedTransportStartTimeoutId !== null) {
|
||||||
|
Tone.getContext().clearTimeout(this.delayedTransportStartTimeoutId);
|
||||||
|
this.delayedTransportStartTimeoutId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.delayedTransportStartSeconds = 0;
|
||||||
|
this.virtualPrerollStartBeat = null;
|
||||||
|
this.virtualPrerollStartAudioTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== PRIVATE UTILITY METHODS =====
|
// ===== PRIVATE UTILITY METHODS =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -200,14 +200,8 @@ export class KGAudioPlayerBus {
|
|||||||
*/
|
*/
|
||||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||||
try {
|
try {
|
||||||
let effectiveVolume = this.volume;
|
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
if (this.muted) {
|
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||||
effectiveVolume = 0;
|
|
||||||
} else if (hasSoloedTracks && !this.solo) {
|
|
||||||
effectiveVolume = 0;
|
|
||||||
}
|
|
||||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
|
||||||
this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error applying effective volume for audio player bus:', error);
|
console.error('Error applying effective volume for audio player bus:', error);
|
||||||
}
|
}
|
||||||
@@ -266,9 +260,8 @@ export class KGAudioPlayerBus {
|
|||||||
|
|
||||||
private updateGainVolume(): void {
|
private updateGainVolume(): void {
|
||||||
try {
|
try {
|
||||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
// Convert linear volume to gain value
|
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||||
this.gainNode.gain.value = effectiveVolume;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating gain volume:', error);
|
console.error('Error updating gain volume:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules audible preroll clicks before beat 0 and keeps the beat 0 accent', () => {
|
||||||
|
const metronome = new KGMetronome();
|
||||||
|
const triggerAttackRelease = vi.fn()
|
||||||
|
;(metronome as unknown as { sampler: unknown }).sampler = {
|
||||||
|
loaded: true,
|
||||||
|
triggerAttackRelease,
|
||||||
|
};
|
||||||
|
|
||||||
|
metronome.start(-4, 4, 0.2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(triggerAttackRelease.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);
|
||||||
|
|
||||||
|
const transportLoop = MockLoop.mock.results[0]?.value;
|
||||||
|
expect(transportLoop).toBeDefined();
|
||||||
|
|
||||||
|
transportLoop.callback(0);
|
||||||
|
expect(triggerAttackRelease).toHaveBeenLastCalledWith('C5', '16n', 0.2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels pending preroll clicks when stopped', () => {
|
||||||
|
const metronome = new KGMetronome();
|
||||||
|
const triggerAttackRelease = vi.fn()
|
||||||
|
;(metronome as unknown as { sampler: unknown }).sampler = {
|
||||||
|
loaded: true,
|
||||||
|
triggerAttackRelease,
|
||||||
|
};
|
||||||
|
|
||||||
|
metronome.start(-2, 4, 0.2);
|
||||||
|
metronome.stop();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(triggerAttackRelease).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { KGToneSamplerFactory } from './KGToneSamplerFactory';
|
|||||||
export class KGMetronome {
|
export class KGMetronome {
|
||||||
private loop: Tone.Loop | null = null;
|
private loop: Tone.Loop | null = null;
|
||||||
private sampler: Tone.Sampler | null = null;
|
private sampler: Tone.Sampler | null = null;
|
||||||
|
private prerollTimeoutIds: number[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
||||||
@@ -34,6 +35,8 @@ export class KGMetronome {
|
|||||||
|
|
||||||
const ppq = Tone.Transport.PPQ;
|
const ppq = Tone.Transport.PPQ;
|
||||||
|
|
||||||
|
this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay);
|
||||||
|
|
||||||
this.loop = new Tone.Loop((time) => {
|
this.loop = new Tone.Loop((time) => {
|
||||||
if (this.sampler?.loaded) {
|
if (this.sampler?.loaded) {
|
||||||
// Derive bar position from the exact Transport tick count at the
|
// Derive bar position from the exact Transport tick count at the
|
||||||
@@ -52,6 +55,10 @@ export class KGMetronome {
|
|||||||
|
|
||||||
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
|
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
const context = Tone.getContext();
|
||||||
|
this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId));
|
||||||
|
this.prerollTimeoutIds = [];
|
||||||
|
|
||||||
if (this.loop) {
|
if (this.loop) {
|
||||||
this.loop.dispose();
|
this.loop.dispose();
|
||||||
this.loop = null;
|
this.loop = null;
|
||||||
@@ -65,4 +72,25 @@ export class KGMetronome {
|
|||||||
this.sampler = null;
|
this.sampler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private schedulePrerollClicks(startPositionBeats: number, beatsPerBar: number, playbackDelay: number): void {
|
||||||
|
if (startPositionBeats >= 0 || !this.sampler?.loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondsPerBeat = 60 / Tone.Transport.bpm.value;
|
||||||
|
const context = Tone.getContext();
|
||||||
|
const firstBeat = Math.ceil(startPositionBeats);
|
||||||
|
|
||||||
|
for (let beat = firstBeat; beat < 0; beat += 1) {
|
||||||
|
const waitSeconds = Math.max(0, (beat - startPositionBeats) * secondsPerBeat + playbackDelay);
|
||||||
|
const note = beat % beatsPerBar === 0 ? 'C5' : 'C4';
|
||||||
|
const timeoutId = context.setTimeout(() => {
|
||||||
|
if (this.sampler?.loaded) {
|
||||||
|
this.sampler.triggerAttackRelease(note, '16n', Tone.now());
|
||||||
|
}
|
||||||
|
}, waitSeconds);
|
||||||
|
this.prerollTimeoutIds.push(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { CreateNoteCommand } from './CreateNoteCommand'
|
import { CreateNoteCommand } from './CreateNoteCommand';
|
||||||
import { KGCore } from '../../KGCore'
|
import { KGCore } from '../../KGCore';
|
||||||
import { KGMidiNote } from '../../midi/KGMidiNote'
|
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||||
import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data'
|
import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data';
|
||||||
|
|
||||||
// Mock the KGCore singleton
|
// Mock the KGCore singleton
|
||||||
vi.mock('../../KGCore', () => ({
|
vi.mock('../../KGCore', () => ({
|
||||||
KGCore: {
|
KGCore: {
|
||||||
instance: vi.fn()
|
instance: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Mock generateUniqueId utility
|
// Mock generateUniqueId utility
|
||||||
vi.mock('../../../util/miscUtil', () => ({
|
vi.mock('../../../util/miscUtil', () => ({
|
||||||
generateUniqueId: vi.fn().mockReturnValue('mock-note-id')
|
generateUniqueId: vi.fn().mockReturnValue('mock-note-id')
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Import the mocked function properly
|
// Import the mocked function properly
|
||||||
const { generateUniqueId } = await import('../../../util/miscUtil')
|
const { generateUniqueId } = await import('../../../util/miscUtil');
|
||||||
|
|
||||||
interface MockCore {
|
interface MockCore {
|
||||||
getCurrentProject: ReturnType<typeof vi.fn>
|
getCurrentProject: ReturnType<typeof vi.fn>
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CreateNoteCommand', () => {
|
describe('CreateNoteCommand', () => {
|
||||||
let mockCore: MockCore
|
let mockCore: MockCore;
|
||||||
let mockProject: ReturnType<typeof createMockProject>
|
let mockProject: ReturnType<typeof createMockProject>;
|
||||||
let mockTrack: ReturnType<typeof createMockMidiTrack>
|
let mockTrack: ReturnType<typeof createMockMidiTrack>;
|
||||||
let mockRegion: ReturnType<typeof createMockMidiRegion>
|
let mockRegion: ReturnType<typeof createMockMidiRegion>;
|
||||||
let command: CreateNoteCommand
|
let command: CreateNoteCommand;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Create test data
|
// Create test data
|
||||||
@@ -36,25 +36,25 @@ describe('CreateNoteCommand', () => {
|
|||||||
id: 'test-region',
|
id: 'test-region',
|
||||||
trackId: 'test-track',
|
trackId: 'test-track',
|
||||||
name: 'Test Region'
|
name: 'Test Region'
|
||||||
})
|
});
|
||||||
|
|
||||||
mockTrack = createMockMidiTrack({
|
mockTrack = createMockMidiTrack({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Test Track',
|
name: 'Test Track',
|
||||||
regions: [mockRegion]
|
regions: [mockRegion]
|
||||||
})
|
});
|
||||||
|
|
||||||
mockProject = createMockProject({
|
mockProject = createMockProject({
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
tracks: [mockTrack]
|
tracks: [mockTrack]
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mock KGCore methods
|
// Mock KGCore methods
|
||||||
mockCore = {
|
mockCore = {
|
||||||
getCurrentProject: vi.fn().mockReturnValue(mockProject)
|
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
|
// Create command
|
||||||
command = new CreateNoteCommand(
|
command = new CreateNoteCommand(
|
||||||
@@ -63,135 +63,135 @@ describe('CreateNoteCommand', () => {
|
|||||||
1, // endBeat
|
1, // endBeat
|
||||||
60, // pitch (middle C)
|
60, // pitch (middle C)
|
||||||
80 // velocity
|
80 // velocity
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create command with correct parameters', () => {
|
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
|
// We can't directly test private properties, but we can test execution
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should generate unique ID when not provided', () => {
|
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
|
// The generateUniqueId mock should have been called
|
||||||
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote')
|
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should use provided ID when given', () => {
|
it('should use provided ID when given', () => {
|
||||||
// Clear previous calls
|
// 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
|
// Should not call generateUniqueId when ID is provided
|
||||||
expect(generateUniqueId).not.toHaveBeenCalled()
|
expect(generateUniqueId).not.toHaveBeenCalled();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('execute', () => {
|
describe('execute', () => {
|
||||||
it('should create a note in the target region', () => {
|
it('should create a note in the target region', () => {
|
||||||
// Mock the addNote method
|
// Mock the addNote method
|
||||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||||
|
|
||||||
// Execute the command
|
// Execute the command
|
||||||
command.execute()
|
command.execute();
|
||||||
|
|
||||||
// Verify note was added
|
// Verify note was added
|
||||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Verify the note has correct properties
|
// Verify the note has correct properties
|
||||||
const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote
|
const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote;
|
||||||
expect(addedNote).toBeInstanceOf(KGMidiNote)
|
expect(addedNote).toBeInstanceOf(KGMidiNote);
|
||||||
expect(addedNote.getStartBeat()).toBe(0)
|
expect(addedNote.getStartBeat()).toBe(0);
|
||||||
expect(addedNote.getEndBeat()).toBe(1)
|
expect(addedNote.getEndBeat()).toBe(1);
|
||||||
expect(addedNote.getPitch()).toBe(60)
|
expect(addedNote.getPitch()).toBe(60);
|
||||||
expect(addedNote.getVelocity()).toBe(80)
|
expect(addedNote.getVelocity()).toBe(80);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should throw error for non-existent region', () => {
|
it('should throw error for non-existent region', () => {
|
||||||
// Create command 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
|
// 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', () => {
|
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
|
// Note should be created and stored internally for undo
|
||||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('undo', () => {
|
describe('undo', () => {
|
||||||
it('should remove the created note', () => {
|
it('should remove the created note', () => {
|
||||||
// Execute first to create the note
|
// Execute first to create the note
|
||||||
command.execute()
|
command.execute();
|
||||||
|
|
||||||
// Mock removeNote method
|
// Mock removeNote method
|
||||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
|
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
|
||||||
|
|
||||||
// Undo the command
|
// Undo the command
|
||||||
command.undo()
|
command.undo();
|
||||||
|
|
||||||
// Verify note was removed
|
// Verify note was removed
|
||||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1)
|
expect(removeNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should throw error when undoing without execute', () => {
|
it('should throw error when undoing without execute', () => {
|
||||||
// Try to undo without executing first
|
// 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)', () => {
|
describe('re-execute (redo pattern)', () => {
|
||||||
it('should re-add the note after undo using execute', () => {
|
it('should re-add the note after undo using execute', () => {
|
||||||
// Execute, undo, then execute again (redo pattern)
|
// Execute, undo, then execute again (redo pattern)
|
||||||
command.execute()
|
command.execute();
|
||||||
command.undo()
|
command.undo();
|
||||||
|
|
||||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||||
command.execute() // Commands are re-executed for redo
|
command.execute(); // Commands are re-executed for redo
|
||||||
|
|
||||||
// Note should be added again
|
// Note should be added again
|
||||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getDescription', () => {
|
describe('getDescription', () => {
|
||||||
it('should return descriptive text', () => {
|
it('should return descriptive text', () => {
|
||||||
const description = command.getDescription()
|
const description = command.getDescription();
|
||||||
|
|
||||||
expect(description).toBeDefined()
|
expect(description).toBeDefined();
|
||||||
expect(typeof description).toBe('string')
|
expect(typeof description).toBe('string');
|
||||||
expect(description.length).toBeGreaterThan(0)
|
expect(description.length).toBeGreaterThan(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('command lifecycle', () => {
|
describe('command lifecycle', () => {
|
||||||
it('should support multiple execute/undo cycles', () => {
|
it('should support multiple execute/undo cycles', () => {
|
||||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
|
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
|
||||||
|
|
||||||
// Execute -> Undo -> Execute -> Undo
|
// Execute -> Undo -> Execute -> Undo
|
||||||
command.execute()
|
command.execute();
|
||||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
command.undo()
|
command.undo();
|
||||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1)
|
expect(removeNoteSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
command.execute() // Re-execute for redo
|
command.execute(); // Re-execute for redo
|
||||||
expect(addNoteSpy).toHaveBeenCalledTimes(2)
|
expect(addNoteSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
command.undo()
|
command.undo();
|
||||||
expect(removeNoteSpy).toHaveBeenCalledTimes(2)
|
expect(removeNoteSpy).toHaveBeenCalledTimes(2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -47,9 +47,8 @@ class MockFileHandle {
|
|||||||
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
||||||
async createWritable() {
|
async createWritable() {
|
||||||
const w = new MockWritable();
|
const w = new MockWritable();
|
||||||
const self = this;
|
|
||||||
const origClose = w.close.bind(w);
|
const origClose = w.close.bind(w);
|
||||||
w.close = async () => { self._content = w.data; await origClose(); };
|
w.close = async () => { this._content = w.data; await origClose(); };
|
||||||
return w;
|
return w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface AppConfig {
|
|||||||
enable_audio_capture_for_screen_sharing: boolean;
|
enable_audio_capture_for_screen_sharing: boolean;
|
||||||
lookahead_time: number;
|
lookahead_time: number;
|
||||||
playback_delay: number;
|
playback_delay: number;
|
||||||
|
recording_offset: number;
|
||||||
};
|
};
|
||||||
templates: {
|
templates: {
|
||||||
custom_instructions: string;
|
custom_instructions: string;
|
||||||
@@ -250,7 +251,8 @@ export class ConfigManager {
|
|||||||
audio: {
|
audio: {
|
||||||
enable_audio_capture_for_screen_sharing: false,
|
enable_audio_capture_for_screen_sharing: false,
|
||||||
lookahead_time: 0.05,
|
lookahead_time: 0.05,
|
||||||
playback_delay: 0.2
|
playback_delay: 0.2,
|
||||||
|
recording_offset: 0
|
||||||
},
|
},
|
||||||
templates: {
|
templates: {
|
||||||
custom_instructions: ''
|
custom_instructions: ''
|
||||||
|
|||||||
@@ -19,10 +19,9 @@ class MockFileSystemFileHandle {
|
|||||||
async createWritable() {
|
async createWritable() {
|
||||||
const stream = new MockFileSystemWritableFileStream();
|
const stream = new MockFileSystemWritableFileStream();
|
||||||
// When stream closes, update our content
|
// When stream closes, update our content
|
||||||
const self = this;
|
|
||||||
const origClose = stream.close.bind(stream);
|
const origClose = stream.close.bind(stream);
|
||||||
stream.close = async () => {
|
stream.close = async () => {
|
||||||
self._content = stream.data;
|
this._content = stream.data;
|
||||||
await origClose();
|
await origClose();
|
||||||
};
|
};
|
||||||
return stream;
|
return stream;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export class KGMidiInput {
|
|||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
private connectedInputs: Map<string, MIDIInput> = new Map();
|
private connectedInputs: Map<string, MIDIInput> = new Map();
|
||||||
|
|
||||||
|
// Recording callbacks
|
||||||
|
private onRecordNoteOn: ((pitch: number) => void) | null = null;
|
||||||
|
private onRecordNoteOff: ((pitch: number) => void) | null = null;
|
||||||
|
|
||||||
// Private constructor to prevent direct instantiation
|
// Private constructor to prevent direct instantiation
|
||||||
private constructor() {
|
private constructor() {
|
||||||
console.log("KGMidiInput initialized");
|
console.log("KGMidiInput initialized");
|
||||||
@@ -161,11 +165,13 @@ export class KGMidiInput {
|
|||||||
if (command === 0x90 && velocity > 0) {
|
if (command === 0x90 && velocity > 0) {
|
||||||
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
|
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
|
||||||
this.triggerNoteOn(pitch, velocity);
|
this.triggerNoteOn(pitch, velocity);
|
||||||
|
this.onRecordNoteOn?.(pitch);
|
||||||
}
|
}
|
||||||
// Note Off: command = 0x80 (128) or Note On with velocity 0
|
// Note Off: command = 0x80 (128) or Note On with velocity 0
|
||||||
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
|
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
|
||||||
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
|
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
|
||||||
this.triggerNoteOff(pitch);
|
this.triggerNoteOff(pitch);
|
||||||
|
this.onRecordNoteOff?.(pitch);
|
||||||
}
|
}
|
||||||
// Control Change: command = 0xB0 (176)
|
// Control Change: command = 0xB0 (176)
|
||||||
else if (command === 0xb0) {
|
else if (command === 0xb0) {
|
||||||
@@ -264,6 +270,16 @@ export class KGMidiInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== RECORDING =====
|
||||||
|
|
||||||
|
public setRecordingCallbacks(
|
||||||
|
onNoteOn: ((pitch: number) => void) | null,
|
||||||
|
onNoteOff: ((pitch: number) => void) | null
|
||||||
|
): void {
|
||||||
|
this.onRecordNoteOn = onNoteOn;
|
||||||
|
this.onRecordNoteOff = onNoteOff;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== GETTERS =====
|
// ===== GETTERS =====
|
||||||
|
|
||||||
public getIsInitialized(): boolean {
|
public getIsInitialized(): boolean {
|
||||||
|
|||||||
@@ -1,151 +1,151 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { KGMidiNote } from './KGMidiNote'
|
import { KGMidiNote } from './KGMidiNote';
|
||||||
|
|
||||||
describe('KGMidiNote', () => {
|
describe('KGMidiNote', () => {
|
||||||
let note: KGMidiNote
|
let note: KGMidiNote;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
note = new KGMidiNote('test-note', 0, 1, 60, 80)
|
note = new KGMidiNote('test-note', 0, 1, 60, 80);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create a note with correct properties', () => {
|
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.getId()).toBe('note-1');
|
||||||
expect(testNote.getStartBeat()).toBe(2)
|
expect(testNote.getStartBeat()).toBe(2);
|
||||||
expect(testNote.getEndBeat()).toBe(4)
|
expect(testNote.getEndBeat()).toBe(4);
|
||||||
expect(testNote.getPitch()).toBe(72)
|
expect(testNote.getPitch()).toBe(72);
|
||||||
expect(testNote.getVelocity()).toBe(100)
|
expect(testNote.getVelocity()).toBe(100);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should use default values when not provided', () => {
|
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.getId()).toBe('default-note');
|
||||||
expect(defaultNote.getStartBeat()).toBe(0)
|
expect(defaultNote.getStartBeat()).toBe(0);
|
||||||
expect(defaultNote.getEndBeat()).toBe(0)
|
expect(defaultNote.getEndBeat()).toBe(0);
|
||||||
expect(defaultNote.getPitch()).toBe(0)
|
expect(defaultNote.getPitch()).toBe(0);
|
||||||
expect(defaultNote.getVelocity()).toBe(127)
|
expect(defaultNote.getVelocity()).toBe(127);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getters and setters', () => {
|
describe('getters and setters', () => {
|
||||||
it('should get and set start beat', () => {
|
it('should get and set start beat', () => {
|
||||||
expect(note.getStartBeat()).toBe(0)
|
expect(note.getStartBeat()).toBe(0);
|
||||||
|
|
||||||
note.setStartBeat(1.5)
|
note.setStartBeat(1.5);
|
||||||
expect(note.getStartBeat()).toBe(1.5)
|
expect(note.getStartBeat()).toBe(1.5);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should get and set end beat', () => {
|
it('should get and set end beat', () => {
|
||||||
expect(note.getEndBeat()).toBe(1)
|
expect(note.getEndBeat()).toBe(1);
|
||||||
|
|
||||||
note.setEndBeat(3.5)
|
note.setEndBeat(3.5);
|
||||||
expect(note.getEndBeat()).toBe(3.5)
|
expect(note.getEndBeat()).toBe(3.5);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should get and set pitch', () => {
|
it('should get and set pitch', () => {
|
||||||
expect(note.getPitch()).toBe(60)
|
expect(note.getPitch()).toBe(60);
|
||||||
|
|
||||||
note.setPitch(72)
|
note.setPitch(72);
|
||||||
expect(note.getPitch()).toBe(72)
|
expect(note.getPitch()).toBe(72);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should get and set velocity', () => {
|
it('should get and set velocity', () => {
|
||||||
expect(note.getVelocity()).toBe(80)
|
expect(note.getVelocity()).toBe(80);
|
||||||
|
|
||||||
note.setVelocity(100)
|
note.setVelocity(100);
|
||||||
expect(note.getVelocity()).toBe(100)
|
expect(note.getVelocity()).toBe(100);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should get and set ID', () => {
|
it('should get and set ID', () => {
|
||||||
expect(note.getId()).toBe('test-note')
|
expect(note.getId()).toBe('test-note');
|
||||||
|
|
||||||
note.setId('new-id')
|
note.setId('new-id');
|
||||||
expect(note.getId()).toBe('new-id')
|
expect(note.getId()).toBe('new-id');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('selection', () => {
|
describe('selection', () => {
|
||||||
it('should start unselected', () => {
|
it('should start unselected', () => {
|
||||||
expect(note.isSelected()).toBe(false)
|
expect(note.isSelected()).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should select and deselect', () => {
|
it('should select and deselect', () => {
|
||||||
note.select()
|
note.select();
|
||||||
expect(note.isSelected()).toBe(true)
|
expect(note.isSelected()).toBe(true);
|
||||||
|
|
||||||
note.deselect()
|
note.deselect();
|
||||||
expect(note.isSelected()).toBe(false)
|
expect(note.isSelected()).toBe(false);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('note duration', () => {
|
describe('note duration', () => {
|
||||||
it('should calculate duration correctly', () => {
|
it('should calculate duration correctly', () => {
|
||||||
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80)
|
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80);
|
||||||
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2)
|
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle zero duration', () => {
|
it('should handle zero duration', () => {
|
||||||
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80)
|
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80);
|
||||||
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0)
|
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('pitch validation', () => {
|
describe('pitch validation', () => {
|
||||||
it('should accept valid MIDI pitch range', () => {
|
it('should accept valid MIDI pitch range', () => {
|
||||||
// MIDI pitch range is typically 0-127
|
// MIDI pitch range is typically 0-127
|
||||||
note.setPitch(0)
|
note.setPitch(0);
|
||||||
expect(note.getPitch()).toBe(0)
|
expect(note.getPitch()).toBe(0);
|
||||||
|
|
||||||
note.setPitch(127)
|
note.setPitch(127);
|
||||||
expect(note.getPitch()).toBe(127)
|
expect(note.getPitch()).toBe(127);
|
||||||
|
|
||||||
note.setPitch(60) // Middle C
|
note.setPitch(60); // Middle C
|
||||||
expect(note.getPitch()).toBe(60)
|
expect(note.getPitch()).toBe(60);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('velocity validation', () => {
|
describe('velocity validation', () => {
|
||||||
it('should accept valid MIDI velocity range', () => {
|
it('should accept valid MIDI velocity range', () => {
|
||||||
// MIDI velocity range is typically 0-127
|
// MIDI velocity range is typically 0-127
|
||||||
note.setVelocity(0)
|
note.setVelocity(0);
|
||||||
expect(note.getVelocity()).toBe(0)
|
expect(note.getVelocity()).toBe(0);
|
||||||
|
|
||||||
note.setVelocity(127)
|
note.setVelocity(127);
|
||||||
expect(note.getVelocity()).toBe(127)
|
expect(note.getVelocity()).toBe(127);
|
||||||
|
|
||||||
note.setVelocity(64) // Mid velocity
|
note.setVelocity(64); // Mid velocity
|
||||||
expect(note.getVelocity()).toBe(64)
|
expect(note.getVelocity()).toBe(64);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('clone and comparison', () => {
|
describe('clone and comparison', () => {
|
||||||
it('should create independent instances', () => {
|
it('should create independent instances', () => {
|
||||||
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80)
|
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80);
|
||||||
const note2 = new KGMidiNote('note-2', 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)
|
note1.setPitch(72);
|
||||||
expect(note2.getPitch()).toBe(60) // Should remain unchanged
|
expect(note2.getPitch()).toBe(60); // Should remain unchanged
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('edge cases', () => {
|
||||||
it('should handle negative start beat', () => {
|
it('should handle negative start beat', () => {
|
||||||
note.setStartBeat(-1)
|
note.setStartBeat(-1);
|
||||||
expect(note.getStartBeat()).toBe(-1)
|
expect(note.getStartBeat()).toBe(-1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle start beat after end beat', () => {
|
it('should handle start beat after end beat', () => {
|
||||||
note.setStartBeat(5)
|
note.setStartBeat(5);
|
||||||
note.setEndBeat(2)
|
note.setEndBeat(2);
|
||||||
|
|
||||||
expect(note.getStartBeat()).toBe(5)
|
expect(note.getStartBeat()).toBe(5);
|
||||||
expect(note.getEndBeat()).toBe(2)
|
expect(note.getEndBeat()).toBe(2);
|
||||||
// Note: The class might need validation logic to prevent this
|
// Note: The class might need validation logic to prevent this
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -5,6 +5,7 @@ import { upgradeToV3 } from './upgradeToV3';
|
|||||||
import { upgradeToV4 } from './upgradeToV4';
|
import { upgradeToV4 } from './upgradeToV4';
|
||||||
import { upgradeToV5 } from './upgradeToV5';
|
import { upgradeToV5 } from './upgradeToV5';
|
||||||
import { upgradeToV6 } from './upgradeToV6';
|
import { upgradeToV6 } from './upgradeToV6';
|
||||||
|
import { upgradeToV7 } from './upgradeToV7';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade the given project to the latest structure version, one version at a time.
|
* Upgrade the given project to the latest structure version, one version at a time.
|
||||||
@@ -48,6 +49,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
|||||||
workingProject = upgradeToV6(workingProject);
|
workingProject = upgradeToV6(workingProject);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 7: {
|
||||||
|
workingProject = upgradeToV7(workingProject);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { KGMidiTrack } from '../track/KGMidiTrack';
|
||||||
|
import { upgradeToV7 } from './upgradeToV7';
|
||||||
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
|
|
||||||
|
function makeProject(volumes: number[]): KGProject {
|
||||||
|
const tracks = volumes.map((v, i) => {
|
||||||
|
const track = new KGMidiTrack(`Track ${i}`, i);
|
||||||
|
// Bypass setVolume clamping to simulate old 0–1 linear values stored in JSON
|
||||||
|
(track as unknown as { volume: number }).volume = v;
|
||||||
|
return track;
|
||||||
|
});
|
||||||
|
const project = new KGProject('Test', 32, 0, 125, undefined, undefined, undefined, undefined, undefined, undefined, tracks, 6);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('upgradeToV7', () => {
|
||||||
|
it('converts 1.0 linear to 0 dB', () => {
|
||||||
|
const project = makeProject([1.0]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getTracks()[0].getVolume()).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 0.8 linear to ~−1.94 dB', () => {
|
||||||
|
const project = makeProject([0.8]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getTracks()[0].getVolume()).toBeCloseTo(20 * Math.log10(0.8), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 0.5 linear to ~−6.02 dB', () => {
|
||||||
|
const project = makeProject([0.5]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getTracks()[0].getVolume()).toBeCloseTo(-6.021, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 0.0 linear to MIN_TRACK_VOLUME_DB', () => {
|
||||||
|
const project = makeProject([0.0]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps values that exceed MAX_TRACK_VOLUME_DB', () => {
|
||||||
|
// linear > 1.0 would give positive dB; cap at +6
|
||||||
|
const project = makeProject([2.0]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getTracks()[0].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bumps project structure version to 7', () => {
|
||||||
|
const project = makeProject([1.0]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
expect(project.getProjectStructureVersion()).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple tracks independently', () => {
|
||||||
|
const project = makeProject([1.0, 0.5, 0.0]);
|
||||||
|
upgradeToV7(project);
|
||||||
|
const tracks = project.getTracks();
|
||||||
|
expect(tracks[0].getVolume()).toBeCloseTo(0, 5);
|
||||||
|
expect(tracks[1].getVolume()).toBeCloseTo(-6.021, 2);
|
||||||
|
expect(tracks[2].getVolume()).toBe(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { KGProject } from '../KGProject';
|
||||||
|
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||||
|
|
||||||
|
export function upgradeToV7(project: KGProject): KGProject {
|
||||||
|
try {
|
||||||
|
// Convert track volumes from 0–1 linear scale to dB
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
const linear = track.getVolume();
|
||||||
|
let db: number;
|
||||||
|
if (linear <= 0) {
|
||||||
|
db = AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||||
|
} else {
|
||||||
|
db = 20 * Math.log10(linear);
|
||||||
|
db = Math.max(AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
|
||||||
|
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, db));
|
||||||
|
}
|
||||||
|
track.setVolume(db);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
project.setProjectStructureVersion(7);
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { KGMidiRegion } from './KGMidiRegion'
|
import { KGMidiRegion } from './KGMidiRegion';
|
||||||
import { KGRegion } from './KGRegion'
|
import { KGRegion } from './KGRegion';
|
||||||
import { KGMidiNote } from '../midi/KGMidiNote'
|
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||||
import { createMockMidiNote } from '../../test/utils/mock-data'
|
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||||
|
|
||||||
describe('KGMidiRegion', () => {
|
describe('KGMidiRegion', () => {
|
||||||
let region: KGMidiRegion
|
let region: KGMidiRegion;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
region = new KGMidiRegion(
|
region = new KGMidiRegion(
|
||||||
@@ -15,8 +15,8 @@ describe('KGMidiRegion', () => {
|
|||||||
'Test Region',
|
'Test Region',
|
||||||
0,
|
0,
|
||||||
4
|
4
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create a MIDI region with correct properties', () => {
|
it('should create a MIDI region with correct properties', () => {
|
||||||
@@ -27,327 +27,327 @@ describe('KGMidiRegion', () => {
|
|||||||
'My Region',
|
'My Region',
|
||||||
4,
|
4,
|
||||||
8
|
8
|
||||||
)
|
);
|
||||||
|
|
||||||
expect(testRegion.getId()).toBe('region-1')
|
expect(testRegion.getId()).toBe('region-1');
|
||||||
expect(testRegion.getTrackId()).toBe('track-1')
|
expect(testRegion.getTrackId()).toBe('track-1');
|
||||||
expect(testRegion.getTrackIndex()).toBe(2)
|
expect(testRegion.getTrackIndex()).toBe(2);
|
||||||
expect(testRegion.getName()).toBe('My Region')
|
expect(testRegion.getName()).toBe('My Region');
|
||||||
expect(testRegion.getStartFromBeat()).toBe(4)
|
expect(testRegion.getStartFromBeat()).toBe(4);
|
||||||
expect(testRegion.getLength()).toBe(8)
|
expect(testRegion.getLength()).toBe(8);
|
||||||
expect(testRegion.getNotes()).toEqual([])
|
expect(testRegion.getNotes()).toEqual([]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should use default values for optional parameters', () => {
|
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.getStartFromBeat()).toBe(0);
|
||||||
expect(defaultRegion.getLength()).toBe(0)
|
expect(defaultRegion.getLength()).toBe(0);
|
||||||
expect(defaultRegion.getNotes()).toEqual([])
|
expect(defaultRegion.getNotes()).toEqual([]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should set the correct type identifier', () => {
|
it('should set the correct type identifier', () => {
|
||||||
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
expect(region.getCurrentType()).toBe('KGMidiRegion');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('note management', () => {
|
describe('note management', () => {
|
||||||
let note1: KGMidiNote
|
let note1: KGMidiNote;
|
||||||
let note2: KGMidiNote
|
let note2: KGMidiNote;
|
||||||
let note3: KGMidiNote
|
let note3: KGMidiNote;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 })
|
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||||
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 })
|
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 });
|
||||||
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 })
|
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 });
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('addNote', () => {
|
describe('addNote', () => {
|
||||||
it('should add a single note to empty region', () => {
|
it('should add a single note to empty region', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(1)
|
expect(notes).toHaveLength(1);
|
||||||
expect(notes[0]).toBe(note1)
|
expect(notes[0]).toBe(note1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should add multiple notes to region', () => {
|
it('should add multiple notes to region', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
region.addNote(note2);
|
||||||
region.addNote(note3)
|
region.addNote(note3);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(3)
|
expect(notes).toHaveLength(3);
|
||||||
expect(notes).toContain(note1)
|
expect(notes).toContain(note1);
|
||||||
expect(notes).toContain(note2)
|
expect(notes).toContain(note2);
|
||||||
expect(notes).toContain(note3)
|
expect(notes).toContain(note3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should maintain note order when adding', () => {
|
it('should maintain note order when adding', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
region.addNote(note2);
|
||||||
region.addNote(note3)
|
region.addNote(note3);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes[0]).toBe(note1)
|
expect(notes[0]).toBe(note1);
|
||||||
expect(notes[1]).toBe(note2)
|
expect(notes[1]).toBe(note2);
|
||||||
expect(notes[2]).toBe(note3)
|
expect(notes[2]).toBe(note3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should allow adding the same note multiple times', () => {
|
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()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(2)
|
expect(notes).toHaveLength(2);
|
||||||
expect(notes[0]).toBe(note1)
|
expect(notes[0]).toBe(note1);
|
||||||
expect(notes[1]).toBe(note1)
|
expect(notes[1]).toBe(note1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('removeNote', () => {
|
describe('removeNote', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
region.addNote(note2);
|
||||||
region.addNote(note3)
|
region.addNote(note3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should remove note by ID', () => {
|
it('should remove note by ID', () => {
|
||||||
region.removeNote('note-2')
|
region.removeNote('note-2');
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(2)
|
expect(notes).toHaveLength(2);
|
||||||
expect(notes).toContain(note1)
|
expect(notes).toContain(note1);
|
||||||
expect(notes).toContain(note3)
|
expect(notes).toContain(note3);
|
||||||
expect(notes).not.toContain(note2)
|
expect(notes).not.toContain(note2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle removing non-existent note gracefully', () => {
|
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', () => {
|
it('should remove all instances when note ID appears multiple times', () => {
|
||||||
// Add another note with same ID as note1
|
// Add another note with same ID as note1
|
||||||
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 })
|
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 });
|
||||||
region.addNote(duplicateNote)
|
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()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(2)
|
expect(notes).toHaveLength(2);
|
||||||
expect(notes).toContain(note2)
|
expect(notes).toContain(note2);
|
||||||
expect(notes).toContain(note3)
|
expect(notes).toContain(note3);
|
||||||
expect(notes).not.toContain(note1)
|
expect(notes).not.toContain(note1);
|
||||||
expect(notes).not.toContain(duplicateNote)
|
expect(notes).not.toContain(duplicateNote);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle removing from empty region', () => {
|
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.removeNote('note-1')).not.toThrow();
|
||||||
expect(emptyRegion.getNotes()).toHaveLength(0)
|
expect(emptyRegion.getNotes()).toHaveLength(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getNotes', () => {
|
describe('getNotes', () => {
|
||||||
it('should return empty array for new region', () => {
|
it('should return empty array for new region', () => {
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toEqual([])
|
expect(notes).toEqual([]);
|
||||||
expect(notes).toHaveLength(0)
|
expect(notes).toHaveLength(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return all notes in region', () => {
|
it('should return all notes in region', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
region.addNote(note2);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(2)
|
expect(notes).toHaveLength(2);
|
||||||
expect(notes).toEqual([note1, note2])
|
expect(notes).toEqual([note1, note2]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return a reference to the internal notes array', () => {
|
it('should return a reference to the internal notes array', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
const notes1 = region.getNotes()
|
const notes1 = region.getNotes();
|
||||||
const notes2 = region.getNotes()
|
const notes2 = region.getNotes();
|
||||||
|
|
||||||
expect(notes1).toBe(notes2) // Same reference
|
expect(notes1).toBe(notes2); // Same reference
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('setNotes', () => {
|
describe('setNotes', () => {
|
||||||
it('should replace all notes with new array', () => {
|
it('should replace all notes with new array', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
region.addNote(note2);
|
||||||
|
|
||||||
expect(region.getNotes()).toHaveLength(2)
|
expect(region.getNotes()).toHaveLength(2);
|
||||||
|
|
||||||
region.setNotes([note3])
|
region.setNotes([note3]);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(1)
|
expect(notes).toHaveLength(1);
|
||||||
expect(notes[0]).toBe(note3)
|
expect(notes[0]).toBe(note3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should allow setting empty notes array', () => {
|
it('should allow setting empty notes array', () => {
|
||||||
region.addNote(note1)
|
region.addNote(note1);
|
||||||
region.addNote(note2)
|
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', () => {
|
it('should accept notes array with multiple notes', () => {
|
||||||
const newNotes = [note1, note2, note3]
|
const newNotes = [note1, note2, note3];
|
||||||
region.setNotes(newNotes)
|
region.setNotes(newNotes);
|
||||||
|
|
||||||
const retrievedNotes = region.getNotes()
|
const retrievedNotes = region.getNotes();
|
||||||
expect(retrievedNotes).toHaveLength(3)
|
expect(retrievedNotes).toHaveLength(3);
|
||||||
expect(retrievedNotes).toEqual(newNotes)
|
expect(retrievedNotes).toEqual(newNotes);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('inheritance from KGRegion', () => {
|
describe('inheritance from KGRegion', () => {
|
||||||
it('should inherit all base region properties', () => {
|
it('should inherit all base region properties', () => {
|
||||||
expect(region.getId()).toBe('test-region-1')
|
expect(region.getId()).toBe('test-region-1');
|
||||||
expect(region.getTrackId()).toBe('test-track-1')
|
expect(region.getTrackId()).toBe('test-track-1');
|
||||||
expect(region.getTrackIndex()).toBe(0)
|
expect(region.getTrackIndex()).toBe(0);
|
||||||
expect(region.getName()).toBe('Test Region')
|
expect(region.getName()).toBe('Test Region');
|
||||||
expect(region.getStartFromBeat()).toBe(0)
|
expect(region.getStartFromBeat()).toBe(0);
|
||||||
expect(region.getLength()).toBe(4)
|
expect(region.getLength()).toBe(4);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit selection functionality', () => {
|
it('should inherit selection functionality', () => {
|
||||||
expect(region.isSelected()).toBe(false)
|
expect(region.isSelected()).toBe(false);
|
||||||
|
|
||||||
region.select()
|
region.select();
|
||||||
expect(region.isSelected()).toBe(true)
|
expect(region.isSelected()).toBe(true);
|
||||||
|
|
||||||
region.deselect()
|
region.deselect();
|
||||||
expect(region.isSelected()).toBe(false)
|
expect(region.isSelected()).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit setters from base class', () => {
|
it('should inherit setters from base class', () => {
|
||||||
region.setName('Updated Region')
|
region.setName('Updated Region');
|
||||||
expect(region.getName()).toBe('Updated Region')
|
expect(region.getName()).toBe('Updated Region');
|
||||||
|
|
||||||
region.setStartFromBeat(8)
|
region.setStartFromBeat(8);
|
||||||
expect(region.getStartFromBeat()).toBe(8)
|
expect(region.getStartFromBeat()).toBe(8);
|
||||||
|
|
||||||
region.setLength(12)
|
region.setLength(12);
|
||||||
expect(region.getLength()).toBe(12)
|
expect(region.getLength()).toBe(12);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('type identification', () => {
|
describe('type identification', () => {
|
||||||
it('should return correct current type', () => {
|
it('should return correct current type', () => {
|
||||||
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
expect(region.getCurrentType()).toBe('KGMidiRegion');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return correct root type', () => {
|
it('should return correct root type', () => {
|
||||||
expect(region.getRootType()).toBe('KGRegion')
|
expect(region.getRootType()).toBe('KGRegion');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should be instanceof both KGMidiRegion and KGRegion', () => {
|
it('should be instanceof both KGMidiRegion and KGRegion', () => {
|
||||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||||
expect(region).toBeInstanceOf(KGRegion)
|
expect(region).toBeInstanceOf(KGRegion);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle notes with overlapping time ranges', () => {
|
it('should handle notes with overlapping time ranges', () => {
|
||||||
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 })
|
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 overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 });
|
||||||
|
|
||||||
region.addNote(overlappingNote1)
|
region.addNote(overlappingNote1);
|
||||||
region.addNote(overlappingNote2)
|
region.addNote(overlappingNote2);
|
||||||
|
|
||||||
const notes = region.getNotes()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(2)
|
expect(notes).toHaveLength(2);
|
||||||
expect(notes).toContain(overlappingNote1)
|
expect(notes).toContain(overlappingNote1);
|
||||||
expect(notes).toContain(overlappingNote2)
|
expect(notes).toContain(overlappingNote2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle notes with same pitch but different timing', () => {
|
it('should handle notes with same pitch but different timing', () => {
|
||||||
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 })
|
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 sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 });
|
||||||
|
|
||||||
region.addNote(sameNote1)
|
region.addNote(sameNote1);
|
||||||
region.addNote(sameNote2)
|
region.addNote(sameNote2);
|
||||||
|
|
||||||
expect(region.getNotes()).toHaveLength(2)
|
expect(region.getNotes()).toHaveLength(2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle notes outside region boundaries', () => {
|
it('should handle notes outside region boundaries', () => {
|
||||||
// Region is from beat 0 to 4, but note extends beyond
|
// 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()
|
const notes = region.getNotes();
|
||||||
expect(notes).toHaveLength(1)
|
expect(notes).toHaveLength(1);
|
||||||
expect(notes[0]).toBe(outsideNote)
|
expect(notes[0]).toBe(outsideNote);
|
||||||
// Note: The region doesn't enforce boundary constraints - that's application logic
|
// Note: The region doesn't enforce boundary constraints - that's application logic
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle zero-length region', () => {
|
it('should handle zero-length region', () => {
|
||||||
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0)
|
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0);
|
||||||
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 })
|
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||||
|
|
||||||
zeroRegion.addNote(note)
|
zeroRegion.addNote(note);
|
||||||
|
|
||||||
expect(zeroRegion.getNotes()).toHaveLength(1)
|
expect(zeroRegion.getNotes()).toHaveLength(1);
|
||||||
expect(zeroRegion.getLength()).toBe(0)
|
expect(zeroRegion.getLength()).toBe(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('data consistency', () => {
|
describe('data consistency', () => {
|
||||||
it('should maintain note references correctly', () => {
|
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)
|
region.addNote(originalNote);
|
||||||
const retrievedNote = region.getNotes()[0]
|
const retrievedNote = region.getNotes()[0];
|
||||||
|
|
||||||
expect(retrievedNote).toBe(originalNote) // Same reference
|
expect(retrievedNote).toBe(originalNote); // Same reference
|
||||||
|
|
||||||
// Modify original note
|
// Modify original note
|
||||||
originalNote.setPitch(64)
|
originalNote.setPitch(64);
|
||||||
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
|
expect(retrievedNote.getPitch()).toBe(64); // Should reflect change
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle concurrent modifications correctly', () => {
|
it('should handle concurrent modifications correctly', () => {
|
||||||
const notes = [
|
const notes = [
|
||||||
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
|
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
|
||||||
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
|
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
|
||||||
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
|
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
|
||||||
]
|
];
|
||||||
|
|
||||||
// Add notes
|
// Add notes
|
||||||
notes.forEach(note => region.addNote(note))
|
notes.forEach(note => region.addNote(note));
|
||||||
expect(region.getNotes()).toHaveLength(3)
|
expect(region.getNotes()).toHaveLength(3);
|
||||||
|
|
||||||
// Remove middle note
|
// Remove middle note
|
||||||
region.removeNote('concurrent-2')
|
region.removeNote('concurrent-2');
|
||||||
expect(region.getNotes()).toHaveLength(2)
|
expect(region.getNotes()).toHaveLength(2);
|
||||||
|
|
||||||
// Add new note
|
// Add new note
|
||||||
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 })
|
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 });
|
||||||
region.addNote(newNote)
|
region.addNote(newNote);
|
||||||
expect(region.getNotes()).toHaveLength(3)
|
expect(region.getNotes()).toHaveLength(3);
|
||||||
|
|
||||||
// Verify final state
|
// Verify final state
|
||||||
const finalNotes = region.getNotes()
|
const finalNotes = region.getNotes();
|
||||||
expect(finalNotes).toContain(notes[0]) // concurrent-1
|
expect(finalNotes).toContain(notes[0]); // concurrent-1
|
||||||
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed)
|
expect(finalNotes).not.toContain(notes[1]); // concurrent-2 (removed)
|
||||||
expect(finalNotes).toContain(notes[2]) // concurrent-3
|
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
||||||
expect(finalNotes).toContain(newNote) // concurrent-4
|
expect(finalNotes).toContain(newNote); // concurrent-4
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
+208
-202
@@ -1,70 +1,70 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'
|
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack';
|
||||||
import { KGTrack, TrackType } from './KGTrack'
|
import { KGTrack, TrackType } from './KGTrack';
|
||||||
import { KGMidiRegion } from '../region/KGMidiRegion'
|
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||||
import { createMockMidiRegion } from '../../test/utils/mock-data'
|
import { createMockMidiRegion } from '../../test/utils/mock-data';
|
||||||
|
|
||||||
describe('KGMidiTrack', () => {
|
describe('KGMidiTrack', () => {
|
||||||
let track: KGMidiTrack
|
let track: KGMidiTrack;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
track = new KGMidiTrack()
|
track = new KGMidiTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should create track with default values', () => {
|
it('should create track with default values', () => {
|
||||||
const defaultTrack = new KGMidiTrack()
|
const defaultTrack = new KGMidiTrack();
|
||||||
|
|
||||||
expect(defaultTrack.getName()).toBe('Untitled MIDI Track')
|
expect(defaultTrack.getName()).toBe('Untitled MIDI Track');
|
||||||
expect(defaultTrack.getId()).toBe(0)
|
expect(defaultTrack.getId()).toBe(0);
|
||||||
expect(defaultTrack.getType()).toBe(TrackType.MIDI)
|
expect(defaultTrack.getType()).toBe(TrackType.MIDI);
|
||||||
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano')
|
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano');
|
||||||
expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME
|
expect(defaultTrack.getVolume()).toBe(0); // DEFAULT_TRACK_VOLUME (0 dB)
|
||||||
expect(defaultTrack.getRegions()).toEqual([])
|
expect(defaultTrack.getRegions()).toEqual([]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should create track with custom parameters', () => {
|
it('should create track with custom parameters', () => {
|
||||||
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
|
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4);
|
||||||
|
|
||||||
expect(customTrack.getName()).toBe('My Piano Track')
|
expect(customTrack.getName()).toBe('My Piano Track');
|
||||||
expect(customTrack.getId()).toBe(5)
|
expect(customTrack.getId()).toBe(5);
|
||||||
expect(customTrack.getType()).toBe(TrackType.MIDI)
|
expect(customTrack.getType()).toBe(TrackType.MIDI);
|
||||||
expect(customTrack.getInstrument()).toBe('electric_piano_1')
|
expect(customTrack.getInstrument()).toBe('electric_piano_1');
|
||||||
expect(customTrack.getVolume()).toBe(0.6)
|
expect(customTrack.getVolume()).toBe(-4);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should set correct type identifier', () => {
|
it('should set correct type identifier', () => {
|
||||||
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
expect(track.getCurrentType()).toBe('KGMidiTrack');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit from KGTrack', () => {
|
it('should inherit from KGTrack', () => {
|
||||||
expect(track).toBeInstanceOf(KGMidiTrack)
|
expect(track).toBeInstanceOf(KGMidiTrack);
|
||||||
expect(track).toBeInstanceOf(KGTrack)
|
expect(track).toBeInstanceOf(KGTrack);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('instrument management', () => {
|
describe('instrument management', () => {
|
||||||
describe('getInstrument', () => {
|
describe('getInstrument', () => {
|
||||||
it('should return default instrument when not set', () => {
|
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', () => {
|
it('should return current instrument', () => {
|
||||||
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean')
|
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean');
|
||||||
expect(customTrack.getInstrument()).toBe('electric_guitar_clean')
|
expect(customTrack.getInstrument()).toBe('electric_guitar_clean');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should provide backward compatibility for undefined instrument', () => {
|
it('should provide backward compatibility for undefined instrument', () => {
|
||||||
// This tests the backward compatibility mentioned in the code
|
// 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', () => {
|
describe('setInstrument', () => {
|
||||||
it('should update instrument', () => {
|
it('should update instrument', () => {
|
||||||
track.setInstrument('violin')
|
track.setInstrument('violin');
|
||||||
expect(track.getInstrument()).toBe('violin')
|
expect(track.getInstrument()).toBe('violin');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle different instrument types', () => {
|
it('should handle different instrument types', () => {
|
||||||
const instruments: InstrumentType[] = [
|
const instruments: InstrumentType[] = [
|
||||||
@@ -75,28 +75,28 @@ describe('KGMidiTrack', () => {
|
|||||||
'violin',
|
'violin',
|
||||||
'trumpet',
|
'trumpet',
|
||||||
'flute'
|
'flute'
|
||||||
]
|
];
|
||||||
|
|
||||||
instruments.forEach(instrument => {
|
instruments.forEach(instrument => {
|
||||||
track.setInstrument(instrument)
|
track.setInstrument(instrument);
|
||||||
expect(track.getInstrument()).toBe(instrument)
|
expect(track.getInstrument()).toBe(instrument);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle rapid instrument changes', () => {
|
it('should handle rapid instrument changes', () => {
|
||||||
track.setInstrument('piano')
|
track.setInstrument('piano');
|
||||||
track.setInstrument('guitar')
|
track.setInstrument('guitar');
|
||||||
track.setInstrument('violin')
|
track.setInstrument('violin');
|
||||||
|
|
||||||
expect(track.getInstrument()).toBe('violin')
|
expect(track.getInstrument()).toBe('violin');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('region management', () => {
|
describe('region management', () => {
|
||||||
let region1: KGMidiRegion
|
let region1: KGMidiRegion;
|
||||||
let region2: KGMidiRegion
|
let region2: KGMidiRegion;
|
||||||
let region3: KGMidiRegion
|
let region3: KGMidiRegion;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
region1 = createMockMidiRegion({
|
region1 = createMockMidiRegion({
|
||||||
@@ -105,137 +105,137 @@ describe('KGMidiTrack', () => {
|
|||||||
name: 'Region 1',
|
name: 'Region 1',
|
||||||
startFromBeat: 0,
|
startFromBeat: 0,
|
||||||
length: 4
|
length: 4
|
||||||
})
|
});
|
||||||
region2 = createMockMidiRegion({
|
region2 = createMockMidiRegion({
|
||||||
id: 'region-2',
|
id: 'region-2',
|
||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
name: 'Region 2',
|
name: 'Region 2',
|
||||||
startFromBeat: 4,
|
startFromBeat: 4,
|
||||||
length: 4
|
length: 4
|
||||||
})
|
});
|
||||||
region3 = createMockMidiRegion({
|
region3 = createMockMidiRegion({
|
||||||
id: 'region-3',
|
id: 'region-3',
|
||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
name: 'Region 3',
|
name: 'Region 3',
|
||||||
startFromBeat: 8,
|
startFromBeat: 8,
|
||||||
length: 4
|
length: 4
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('setRegions', () => {
|
describe('setRegions', () => {
|
||||||
it('should set regions array', () => {
|
it('should set regions array', () => {
|
||||||
const regions = [region1, region2]
|
const regions = [region1, region2];
|
||||||
track.setRegions(regions)
|
track.setRegions(regions);
|
||||||
|
|
||||||
expect(track.getRegions()).toHaveLength(2)
|
expect(track.getRegions()).toHaveLength(2);
|
||||||
expect(track.getRegions()).toEqual(regions)
|
expect(track.getRegions()).toEqual(regions);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should replace existing regions', () => {
|
it('should replace existing regions', () => {
|
||||||
track.setRegions([region1])
|
track.setRegions([region1]);
|
||||||
expect(track.getRegions()).toHaveLength(1)
|
expect(track.getRegions()).toHaveLength(1);
|
||||||
|
|
||||||
track.setRegions([region2, region3])
|
track.setRegions([region2, region3]);
|
||||||
expect(track.getRegions()).toHaveLength(2)
|
expect(track.getRegions()).toHaveLength(2);
|
||||||
expect(track.getRegions()).toContain(region2)
|
expect(track.getRegions()).toContain(region2);
|
||||||
expect(track.getRegions()).toContain(region3)
|
expect(track.getRegions()).toContain(region3);
|
||||||
expect(track.getRegions()).not.toContain(region1)
|
expect(track.getRegions()).not.toContain(region1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should accept empty array', () => {
|
it('should accept empty array', () => {
|
||||||
track.setRegions([region1, region2])
|
track.setRegions([region1, region2]);
|
||||||
track.setRegions([])
|
track.setRegions([]);
|
||||||
|
|
||||||
expect(track.getRegions()).toHaveLength(0)
|
expect(track.getRegions()).toHaveLength(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should enforce KGMidiRegion type', () => {
|
it('should enforce KGMidiRegion type', () => {
|
||||||
const regions: KGMidiRegion[] = [region1, region2]
|
const regions: KGMidiRegion[] = [region1, region2];
|
||||||
track.setRegions(regions)
|
track.setRegions(regions);
|
||||||
|
|
||||||
const retrievedRegions = track.getRegions()
|
const retrievedRegions = track.getRegions();
|
||||||
retrievedRegions.forEach(region => {
|
retrievedRegions.forEach(region => {
|
||||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('inherited region methods', () => {
|
describe('inherited region methods', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
track.setRegions([region1, region2])
|
track.setRegions([region1, region2]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit addRegion method', () => {
|
it('should inherit addRegion method', () => {
|
||||||
track.addRegion(region3)
|
track.addRegion(region3);
|
||||||
|
|
||||||
const regions = track.getRegions()
|
const regions = track.getRegions();
|
||||||
expect(regions).toHaveLength(3)
|
expect(regions).toHaveLength(3);
|
||||||
expect(regions).toContain(region3)
|
expect(regions).toContain(region3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit removeRegion method', () => {
|
it('should inherit removeRegion method', () => {
|
||||||
track.removeRegion('region-1')
|
track.removeRegion('region-1');
|
||||||
|
|
||||||
const regions = track.getRegions()
|
const regions = track.getRegions();
|
||||||
expect(regions).toHaveLength(1)
|
expect(regions).toHaveLength(1);
|
||||||
expect(regions).not.toContain(region1)
|
expect(regions).not.toContain(region1);
|
||||||
expect(regions).toContain(region2)
|
expect(regions).toContain(region2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit getRegions method', () => {
|
it('should inherit getRegions method', () => {
|
||||||
const regions = track.getRegions()
|
const regions = track.getRegions();
|
||||||
expect(regions).toHaveLength(2)
|
expect(regions).toHaveLength(2);
|
||||||
expect(regions).toContain(region1)
|
expect(regions).toContain(region1);
|
||||||
expect(regions).toContain(region2)
|
expect(regions).toContain(region2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('inheritance from KGTrack', () => {
|
describe('inheritance from KGTrack', () => {
|
||||||
it('should inherit all base track properties', () => {
|
it('should inherit all base track properties', () => {
|
||||||
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
|
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1);
|
||||||
|
|
||||||
expect(customTrack.getName()).toBe('Test Track')
|
expect(customTrack.getName()).toBe('Test Track');
|
||||||
expect(customTrack.getId()).toBe(42)
|
expect(customTrack.getId()).toBe(42);
|
||||||
expect(customTrack.getType()).toBe(TrackType.MIDI)
|
expect(customTrack.getType()).toBe(TrackType.MIDI);
|
||||||
expect(customTrack.getVolume()).toBe(0.9)
|
expect(customTrack.getVolume()).toBe(-1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit base track setters', () => {
|
it('should inherit base track setters', () => {
|
||||||
track.setName('Updated Track')
|
track.setName('Updated Track');
|
||||||
expect(track.getName()).toBe('Updated Track')
|
expect(track.getName()).toBe('Updated Track');
|
||||||
|
|
||||||
track.setVolume(0.5)
|
track.setVolume(-6);
|
||||||
expect(track.getVolume()).toBe(0.5)
|
expect(track.getVolume()).toBe(-6);
|
||||||
|
|
||||||
track.setTrackIndex(3)
|
track.setTrackIndex(3);
|
||||||
expect(track.getTrackIndex()).toBe(3)
|
expect(track.getTrackIndex()).toBe(3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should inherit volume controls', () => {
|
it('should inherit volume controls', () => {
|
||||||
expect(track.getVolume()).toBe(0.8) // Default volume
|
expect(track.getVolume()).toBe(0); // Default volume (0 dB)
|
||||||
|
|
||||||
track.setVolume(0.5)
|
track.setVolume(-6);
|
||||||
expect(track.getVolume()).toBe(0.5)
|
expect(track.getVolume()).toBe(-6);
|
||||||
|
|
||||||
track.setVolume(1.0)
|
track.setVolume(0);
|
||||||
expect(track.getVolume()).toBe(1.0)
|
expect(track.getVolume()).toBe(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('type identification', () => {
|
describe('type identification', () => {
|
||||||
it('should return correct current type', () => {
|
it('should return correct current type', () => {
|
||||||
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
expect(track.getCurrentType()).toBe('KGMidiTrack');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return correct root type', () => {
|
it('should return correct root type', () => {
|
||||||
expect(track.getRootType()).toBe('KGTrack')
|
expect(track.getRootType()).toBe('KGTrack');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should have MIDI track type', () => {
|
it('should have MIDI track type', () => {
|
||||||
expect(track.getType()).toBe(TrackType.MIDI)
|
expect(track.getType()).toBe(TrackType.MIDI);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('instrument type validation', () => {
|
describe('instrument type validation', () => {
|
||||||
it('should handle all valid General MIDI instruments', () => {
|
it('should handle all valid General MIDI instruments', () => {
|
||||||
@@ -259,94 +259,100 @@ describe('KGMidiTrack', () => {
|
|||||||
'clarinet',
|
'clarinet',
|
||||||
'soprano_sax',
|
'soprano_sax',
|
||||||
'alto_sax'
|
'alto_sax'
|
||||||
]
|
];
|
||||||
|
|
||||||
validInstruments.forEach(instrument => {
|
validInstruments.forEach(instrument => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
track.setInstrument(instrument)
|
track.setInstrument(instrument);
|
||||||
expect(track.getInstrument()).toBe(instrument)
|
expect(track.getInstrument()).toBe(instrument);
|
||||||
}).not.toThrow()
|
}).not.toThrow();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('track state consistency', () => {
|
describe('track state consistency', () => {
|
||||||
it('should maintain consistent state after multiple operations', () => {
|
it('should maintain consistent state after multiple operations', () => {
|
||||||
// Setup initial state
|
// Setup initial state
|
||||||
track.setName('Piano Track')
|
track.setName('Piano Track');
|
||||||
track.setInstrument('acoustic_grand_piano')
|
track.setInstrument('acoustic_grand_piano');
|
||||||
track.setVolume(0.7)
|
track.setVolume(-3);
|
||||||
|
|
||||||
const regions = [
|
const regions = [
|
||||||
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
|
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
|
||||||
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
|
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
|
||||||
]
|
];
|
||||||
track.setRegions(regions)
|
track.setRegions(regions);
|
||||||
|
|
||||||
// Verify initial state
|
// Verify initial state
|
||||||
expect(track.getName()).toBe('Piano Track')
|
expect(track.getName()).toBe('Piano Track');
|
||||||
expect(track.getInstrument()).toBe('acoustic_grand_piano')
|
expect(track.getInstrument()).toBe('acoustic_grand_piano');
|
||||||
expect(track.getVolume()).toBe(0.7)
|
expect(track.getVolume()).toBe(-3);
|
||||||
expect(track.getRegions()).toHaveLength(2)
|
expect(track.getRegions()).toHaveLength(2);
|
||||||
|
|
||||||
// Modify state
|
// Modify state
|
||||||
track.setInstrument('electric_piano_1')
|
track.setInstrument('electric_piano_1');
|
||||||
track.addRegion(createMockMidiRegion({
|
track.addRegion(createMockMidiRegion({
|
||||||
id: 'r3',
|
id: 'r3',
|
||||||
trackId: '0',
|
trackId: '0',
|
||||||
name: 'Chorus'
|
name: 'Chorus'
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Verify modified state
|
// Verify modified state
|
||||||
expect(track.getName()).toBe('Piano Track')
|
expect(track.getName()).toBe('Piano Track');
|
||||||
expect(track.getInstrument()).toBe('electric_piano_1')
|
expect(track.getInstrument()).toBe('electric_piano_1');
|
||||||
expect(track.getVolume()).toBe(0.7)
|
expect(track.getVolume()).toBe(-3);
|
||||||
expect(track.getRegions()).toHaveLength(3)
|
expect(track.getRegions()).toHaveLength(3);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle region-track relationship correctly', () => {
|
it('should handle region-track relationship correctly', () => {
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
id: 'test-region',
|
id: 'test-region',
|
||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
name: 'Test Region'
|
name: 'Test Region'
|
||||||
})
|
});
|
||||||
|
|
||||||
track.addRegion(region)
|
track.addRegion(region);
|
||||||
|
|
||||||
// Verify region is in track
|
// Verify region is in track
|
||||||
expect(track.getRegions()).toContain(region)
|
expect(track.getRegions()).toContain(region);
|
||||||
|
|
||||||
// Find region manually since getRegionById doesn't exist
|
// Find region manually since getRegionById doesn't exist
|
||||||
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region')
|
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region');
|
||||||
expect(foundRegion).toBe(region)
|
expect(foundRegion).toBe(region);
|
||||||
|
|
||||||
// Verify region properties
|
// Verify region properties
|
||||||
expect(region.getTrackId()).toBe(track.getId().toString())
|
expect(region.getTrackId()).toBe(track.getId().toString());
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle empty track name', () => {
|
it('should handle empty track name', () => {
|
||||||
const emptyNameTrack = new KGMidiTrack('', 0, 'piano')
|
const emptyNameTrack = new KGMidiTrack('', 0, 'piano');
|
||||||
expect(emptyNameTrack.getName()).toBe('')
|
expect(emptyNameTrack.getName()).toBe('');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle negative track ID', () => {
|
it('should handle negative track ID', () => {
|
||||||
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano')
|
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano');
|
||||||
expect(negativeIdTrack.getId()).toBe(-1)
|
expect(negativeIdTrack.getId()).toBe(-1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle volume boundaries', () => {
|
it('should handle volume boundaries', () => {
|
||||||
track.setVolume(0.0)
|
track.setVolume(-60);
|
||||||
expect(track.getVolume()).toBe(0.0)
|
expect(track.getVolume()).toBe(-60);
|
||||||
|
|
||||||
track.setVolume(1.0)
|
track.setVolume(0);
|
||||||
expect(track.getVolume()).toBe(1.0)
|
expect(track.getVolume()).toBe(0);
|
||||||
|
|
||||||
// Volume outside normal range (should still work)
|
track.setVolume(6);
|
||||||
track.setVolume(1.5)
|
expect(track.getVolume()).toBe(6);
|
||||||
expect(track.getVolume()).toBe(1.5)
|
|
||||||
})
|
// Volume outside valid dB range is clamped
|
||||||
|
track.setVolume(10);
|
||||||
|
expect(track.getVolume()).toBe(6);
|
||||||
|
|
||||||
|
track.setVolume(-100);
|
||||||
|
expect(track.getVolume()).toBe(-60);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle large number of regions', () => {
|
it('should handle large number of regions', () => {
|
||||||
const manyRegions = Array.from({ length: 100 }, (_, i) =>
|
const manyRegions = Array.from({ length: 100 }, (_, i) =>
|
||||||
@@ -355,44 +361,44 @@ describe('KGMidiTrack', () => {
|
|||||||
trackId: track.getId().toString(),
|
trackId: track.getId().toString(),
|
||||||
name: `Region ${i}`
|
name: `Region ${i}`
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
track.setRegions(manyRegions)
|
track.setRegions(manyRegions);
|
||||||
expect(track.getRegions()).toHaveLength(100)
|
expect(track.getRegions()).toHaveLength(100);
|
||||||
|
|
||||||
// Should be able to find any region
|
// Should be able to find any region
|
||||||
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50')
|
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50');
|
||||||
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99')
|
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99');
|
||||||
|
|
||||||
expect(foundRegion50).toBeDefined()
|
expect(foundRegion50).toBeDefined();
|
||||||
expect(foundRegion99).toBeDefined()
|
expect(foundRegion99).toBeDefined();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('class-transformer compatibility', () => {
|
describe('class-transformer compatibility', () => {
|
||||||
it('should have proper type annotations for serialization', () => {
|
it('should have proper type annotations for serialization', () => {
|
||||||
// Verify the class has the necessary decorators 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
|
// Test that default instrument fallback works
|
||||||
const instrument = track.getInstrument()
|
const instrument = track.getInstrument();
|
||||||
expect(instrument).toBe('acoustic_grand_piano')
|
expect(instrument).toBe('acoustic_grand_piano');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should maintain regions type after serialization simulation', () => {
|
it('should maintain regions type after serialization simulation', () => {
|
||||||
const regions = [
|
const regions = [
|
||||||
createMockMidiRegion({ id: 'r1', trackId: '0' }),
|
createMockMidiRegion({ id: 'r1', trackId: '0' }),
|
||||||
createMockMidiRegion({ id: 'r2', trackId: '0' })
|
createMockMidiRegion({ id: 'r2', trackId: '0' })
|
||||||
]
|
];
|
||||||
|
|
||||||
track.setRegions(regions)
|
track.setRegions(regions);
|
||||||
|
|
||||||
// Simulate what happens during serialization/deserialization
|
// Simulate what happens during serialization/deserialization
|
||||||
const retrievedRegions = track.getRegions()
|
const retrievedRegions = track.getRegions();
|
||||||
expect(retrievedRegions).toHaveLength(2)
|
expect(retrievedRegions).toHaveLength(2);
|
||||||
retrievedRegions.forEach(region => {
|
retrievedRegions.forEach(region => {
|
||||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -104,7 +104,10 @@ export class KGTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setVolume(volume: number): void {
|
public setVolume(volume: number): void {
|
||||||
this.volume = volume;
|
this.volume = Math.max(
|
||||||
|
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
|
||||||
|
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, volume)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRegions(regions: KGRegion[]): void {
|
public setRegions(regions: KGRegion[]): void {
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { saveProject } from '../util/saveUtil';
|
|||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
import { ConfigManager } from '../core/config/ConfigManager';
|
||||||
import { useProjectStore } from '../stores/projectStore';
|
import { useProjectStore } from '../stores/projectStore';
|
||||||
import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
|
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
|
* Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations
|
||||||
* Handles keyboard shortcuts defined in the configuration
|
* Handles keyboard shortcuts defined in the configuration
|
||||||
*/
|
*/
|
||||||
export const useGlobalKeyboardHandler = () => {
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -64,6 +67,7 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
const playShortcut = configManager.get('hotkeys.main.play') as string;
|
const playShortcut = configManager.get('hotkeys.main.play') as string;
|
||||||
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
||||||
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
||||||
|
const recordShortcut = configManager.get('hotkeys.main.record') as string;
|
||||||
|
|
||||||
// Check for undo shortcut
|
// Check for undo shortcut
|
||||||
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
||||||
@@ -153,6 +157,42 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return;
|
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
|
// Check for save shortcut
|
||||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -176,5 +216,5 @@ export const useGlobalKeyboardHandler = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
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
|
||||||
};
|
};
|
||||||
+123
-4
@@ -18,6 +18,10 @@ import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrad
|
|||||||
import { toggleLoop } from '../util/loopUtil';
|
import { toggleLoop } from '../util/loopUtil';
|
||||||
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
|
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||||
|
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||||
|
import { CreateNotesCommand } from '../core/commands/note/CreateNotesCommand';
|
||||||
|
import type { NoteCreationData } from '../core/commands/note/CreateNotesCommand';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update CSS custom property for time signature numerator
|
* Update CSS custom property for time signature numerator
|
||||||
@@ -92,6 +96,12 @@ interface ProjectState {
|
|||||||
// Settings state
|
// Settings state
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
|
|
||||||
|
// Recording state
|
||||||
|
isRecording: boolean;
|
||||||
|
recordingTargetRegionId: string | null;
|
||||||
|
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>;
|
||||||
|
recordingOriginalPlayhead: number;
|
||||||
|
|
||||||
// Undo/redo state
|
// Undo/redo state
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -167,11 +177,19 @@ interface ProjectState {
|
|||||||
|
|
||||||
// Project state refresh actions
|
// Project state refresh actions
|
||||||
refreshProjectState: () => void;
|
refreshProjectState: () => void;
|
||||||
|
|
||||||
|
// Recording actions
|
||||||
|
startRecording: () => Promise<void>;
|
||||||
|
stopRecording: () => Promise<void>;
|
||||||
|
|
||||||
// Initialization actions
|
// Initialization actions
|
||||||
initializeFromConfig: () => Promise<void>;
|
initializeFromConfig: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-level recording state (not reactive — only used for timing during active recording)
|
||||||
|
let _recordingActiveNotes: Map<number, number> = new Map(); // pitch → region-relative startBeat
|
||||||
|
let _recordingRegionStartBeat: number = 0;
|
||||||
|
|
||||||
// Create the store
|
// Create the store
|
||||||
export const useProjectStore = create<ProjectState>((set, get) => {
|
export const useProjectStore = create<ProjectState>((set, get) => {
|
||||||
const currentProject = KGCore.instance().getCurrentProject();
|
const currentProject = KGCore.instance().getCurrentProject();
|
||||||
@@ -308,7 +326,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
canRedo: false,
|
canRedo: false,
|
||||||
undoDescription: null,
|
undoDescription: null,
|
||||||
redoDescription: null,
|
redoDescription: null,
|
||||||
|
|
||||||
|
// Initial recording state
|
||||||
|
isRecording: false,
|
||||||
|
recordingTargetRegionId: null,
|
||||||
|
recordingNotes: [],
|
||||||
|
recordingOriginalPlayhead: 0,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setProjectName: (name: string) => {
|
setProjectName: (name: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -772,6 +796,102 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({ isPlaying: false });
|
set({ isPlaying: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startRecording: async () => {
|
||||||
|
const { activeRegionId, timeSignature, playheadPosition, setPlayheadPosition } = get();
|
||||||
|
|
||||||
|
const project = KGCore.instance().getCurrentProject();
|
||||||
|
let targetRegion: KGMidiRegion | null = null;
|
||||||
|
for (const track of project.getTracks()) {
|
||||||
|
const found = track.getRegions().find(r => r.getId() === activeRegionId);
|
||||||
|
if (found instanceof KGMidiRegion) { targetRegion = found; break; }
|
||||||
|
}
|
||||||
|
if (!targetRegion) return;
|
||||||
|
|
||||||
|
_recordingRegionStartBeat = targetRegion.getStartFromBeat();
|
||||||
|
_recordingActiveNotes = new Map();
|
||||||
|
|
||||||
|
set({
|
||||||
|
isRecording: true,
|
||||||
|
recordingNotes: [],
|
||||||
|
recordingTargetRegionId: activeRegionId,
|
||||||
|
recordingOriginalPlayhead: playheadPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCorrectedBeat = (): number => {
|
||||||
|
const bpm = get().bpm;
|
||||||
|
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||||
|
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
||||||
|
const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60);
|
||||||
|
return KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
|
||||||
|
};
|
||||||
|
|
||||||
|
KGMidiInput.instance().setRecordingCallbacks(
|
||||||
|
(pitch: number) => {
|
||||||
|
const beat = buildCorrectedBeat();
|
||||||
|
_recordingActiveNotes.set(pitch, beat);
|
||||||
|
},
|
||||||
|
(pitch: number) => {
|
||||||
|
const endBeat = buildCorrectedBeat();
|
||||||
|
const startBeat = _recordingActiveNotes.get(pitch);
|
||||||
|
if (startBeat !== undefined) {
|
||||||
|
_recordingActiveNotes.delete(pitch);
|
||||||
|
set(state => ({
|
||||||
|
recordingNotes: [...state.recordingNotes, { pitch, startBeat, endBeat }],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectLooping = project.getIsLooping();
|
||||||
|
const [loopStartBar] = project.getLoopingRange();
|
||||||
|
const loopStartBeat = loopStartBar * timeSignature.numerator;
|
||||||
|
const recordingStartBeat = projectLooping
|
||||||
|
? loopStartBeat - timeSignature.numerator
|
||||||
|
: playheadPosition - timeSignature.numerator;
|
||||||
|
|
||||||
|
setPlayheadPosition(recordingStartBeat);
|
||||||
|
await KGCore.instance().startPlaying({
|
||||||
|
preserveLoopPreroll: projectLooping,
|
||||||
|
});
|
||||||
|
set({ isPlaying: true, autoScrollEnabled: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
stopRecording: async () => {
|
||||||
|
const { recordingNotes, recordingTargetRegionId, recordingOriginalPlayhead, stopPlaying, setPlayheadPosition, refreshProjectState } = get();
|
||||||
|
|
||||||
|
// Finalize any held keys
|
||||||
|
const finalNotes = [...recordingNotes];
|
||||||
|
const bpm = get().bpm;
|
||||||
|
const playbackDelaySec = (ConfigManager.instance().get('audio.playback_delay') as number) ?? 0.2;
|
||||||
|
const recordingOffsetSec = (ConfigManager.instance().get('audio.recording_offset') as number) ?? 0;
|
||||||
|
const correctionBeats = (playbackDelaySec + recordingOffsetSec) * (bpm / 60);
|
||||||
|
const endBeatForHeld = KGAudioInterface.instance().getTransportPosition() - correctionBeats - _recordingRegionStartBeat;
|
||||||
|
|
||||||
|
_recordingActiveNotes.forEach((startBeat, pitch) => {
|
||||||
|
finalNotes.push({ pitch, startBeat, endBeat: endBeatForHeld });
|
||||||
|
});
|
||||||
|
_recordingActiveNotes.clear();
|
||||||
|
|
||||||
|
KGMidiInput.instance().setRecordingCallbacks(null, null);
|
||||||
|
|
||||||
|
if (finalNotes.length > 0 && recordingTargetRegionId) {
|
||||||
|
const noteData: NoteCreationData[] = finalNotes.map(n => ({
|
||||||
|
regionId: recordingTargetRegionId,
|
||||||
|
startBeat: n.startBeat,
|
||||||
|
endBeat: n.endBeat,
|
||||||
|
pitch: n.pitch,
|
||||||
|
velocity: 127,
|
||||||
|
}));
|
||||||
|
const command = new CreateNotesCommand(noteData);
|
||||||
|
KGCore.instance().executeCommand(command);
|
||||||
|
refreshProjectState();
|
||||||
|
}
|
||||||
|
|
||||||
|
await stopPlaying();
|
||||||
|
setPlayheadPosition(recordingOriginalPlayhead);
|
||||||
|
set({ isRecording: false, recordingNotes: [], recordingTargetRegionId: null });
|
||||||
|
},
|
||||||
|
|
||||||
toggleLoop: () => {
|
toggleLoop: () => {
|
||||||
const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get();
|
const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get();
|
||||||
|
|
||||||
@@ -1077,7 +1197,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
set({ showChatBox: defaultChatBoxOpen });
|
set({ showChatBox: defaultChatBoxOpen });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -1086,4 +1206,3 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,334 +2,334 @@
|
|||||||
* Integration tests for command execution and undo/redo functionality
|
* Integration tests for command execution and undo/redo functionality
|
||||||
* Tests the complete flow: Command execution → Core model updates → UI state sync
|
* Tests the complete flow: Command execution → Core model updates → UI state sync
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react'
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
// Import core classes
|
// Import core classes
|
||||||
import { KGCore } from '../../../core/KGCore'
|
import { KGCore } from '../../../core/KGCore';
|
||||||
import { KGProject } from '../../../core/KGProject'
|
import { KGProject } from '../../../core/KGProject';
|
||||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack'
|
import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
|
||||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote'
|
import { KGMidiNote } from '../../../core/midi/KGMidiNote';
|
||||||
import { KGCommandHistory } from '../../../core/commands/KGCommandHistory'
|
import { KGCommandHistory } from '../../../core/commands/KGCommandHistory';
|
||||||
|
|
||||||
// Import commands
|
// Import commands
|
||||||
import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand'
|
import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand';
|
||||||
import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand'
|
import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand';
|
||||||
import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand'
|
import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand';
|
||||||
|
|
||||||
// Import store
|
// Import store
|
||||||
import { useProjectStore } from '../../../stores/projectStore'
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
// Import test utilities
|
// Import test utilities
|
||||||
import '../../utils/setup-integration-tests'
|
import '../../utils/setup-integration-tests';
|
||||||
|
|
||||||
describe('Command Execution Integration Tests', () => {
|
describe('Command Execution Integration Tests', () => {
|
||||||
let testProject: KGProject
|
let testProject: KGProject;
|
||||||
let testTrack: KGMidiTrack
|
let testTrack: KGMidiTrack;
|
||||||
let testRegion: KGMidiRegion
|
let testRegion: KGMidiRegion;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create a real project with track and region for testing
|
// Create a real project with track and region for testing
|
||||||
testProject = new KGProject('Test Project')
|
testProject = new KGProject('Test Project');
|
||||||
testProject.setBpm(120)
|
testProject.setBpm(120);
|
||||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 })
|
testProject.setTimeSignature({ numerator: 4, denominator: 4 });
|
||||||
testProject.setKeySignature('C major')
|
testProject.setKeySignature('C major');
|
||||||
testProject.setMaxBars(32)
|
testProject.setMaxBars(32);
|
||||||
|
|
||||||
// Create test track and region
|
// Create test track and region
|
||||||
testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||||
testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
|
testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
|
||||||
|
|
||||||
// Set up the project hierarchy
|
// Set up the project hierarchy
|
||||||
testTrack.addRegion(testRegion)
|
testTrack.addRegion(testRegion);
|
||||||
testProject.setTracks([testTrack])
|
testProject.setTracks([testTrack]);
|
||||||
|
|
||||||
// Initialize KGCore with test project
|
// Initialize KGCore with test project
|
||||||
const core = KGCore.instance()
|
const core = KGCore.instance();
|
||||||
await core.initialize()
|
await core.initialize();
|
||||||
core.setCurrentProject(testProject)
|
core.setCurrentProject(testProject);
|
||||||
|
|
||||||
// Clear command history
|
// Clear command history
|
||||||
KGCommandHistory.instance().clear()
|
KGCommandHistory.instance().clear();
|
||||||
|
|
||||||
// Initialize store with project
|
// Initialize store with project
|
||||||
const { loadProject } = useProjectStore.getState()
|
const { loadProject } = useProjectStore.getState();
|
||||||
await loadProject(testProject)
|
await loadProject(testProject);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Note Command Integration', () => {
|
describe('Note Command Integration', () => {
|
||||||
it('should execute CreateNoteCommand and update both core model and store', async () => {
|
it('should execute CreateNoteCommand and update both core model and store', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const initialNoteCount = testRegion.getNotes().length
|
const initialNoteCount = testRegion.getNotes().length;
|
||||||
|
|
||||||
// Create and execute command
|
// Create and execute command
|
||||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
|
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
// Execute command through command history (simulates real usage)
|
// Execute command through command history (simulates real usage)
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createCommand)
|
commandHistory.executeCommand(createCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1)
|
expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||||
|
|
||||||
const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId())
|
const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId());
|
||||||
expect(createdNote).toBeDefined()
|
expect(createdNote).toBeDefined();
|
||||||
expect(createdNote!.getPitch()).toBe(60)
|
expect(createdNote!.getPitch()).toBe(60);
|
||||||
expect(createdNote!.getStartBeat()).toBe(0)
|
expect(createdNote!.getStartBeat()).toBe(0);
|
||||||
expect(createdNote!.getEndBeat()).toBe(1)
|
expect(createdNote!.getEndBeat()).toBe(1);
|
||||||
|
|
||||||
// Verify command history state
|
// Verify command history state
|
||||||
expect(commandHistory.canUndo()).toBe(true)
|
expect(commandHistory.canUndo()).toBe(true);
|
||||||
expect(commandHistory.canRedo()).toBe(false)
|
expect(commandHistory.canRedo()).toBe(false);
|
||||||
expect(commandHistory.getUndoDescription()).toBe('Create note C4')
|
expect(commandHistory.getUndoDescription()).toBe('Create note C4');
|
||||||
|
|
||||||
// Verify store undo/redo state is updated
|
// Verify store undo/redo state is updated
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.canUndo).toBe(true)
|
expect(storeState.canUndo).toBe(true);
|
||||||
expect(storeState.canRedo).toBe(false)
|
expect(storeState.canRedo).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should execute undo and restore previous state', async () => {
|
it('should execute undo and restore previous state', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const initialNoteCount = testRegion.getNotes().length
|
const initialNoteCount = testRegion.getNotes().length;
|
||||||
|
|
||||||
// Create and execute command
|
// Create and execute command
|
||||||
const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120) // E4
|
const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120); // E4
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createCommand)
|
commandHistory.executeCommand(createCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify note was created
|
// Verify note was created
|
||||||
const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1)
|
expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||||
|
|
||||||
// Execute undo
|
// Execute undo
|
||||||
act(() => {
|
act(() => {
|
||||||
const undoSuccess = commandHistory.undo()
|
const undoSuccess = commandHistory.undo();
|
||||||
expect(undoSuccess).toBe(true)
|
expect(undoSuccess).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was restored
|
// Verify core model was restored
|
||||||
const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount)
|
expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount);
|
||||||
|
|
||||||
// Verify the specific note was removed
|
// Verify the specific note was removed
|
||||||
const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId())
|
const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId());
|
||||||
expect(noteExists).toBe(false)
|
expect(noteExists).toBe(false);
|
||||||
|
|
||||||
// Verify command history state
|
// Verify command history state
|
||||||
expect(commandHistory.canUndo()).toBe(false)
|
expect(commandHistory.canUndo()).toBe(false);
|
||||||
expect(commandHistory.canRedo()).toBe(true)
|
expect(commandHistory.canRedo()).toBe(true);
|
||||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4')
|
expect(commandHistory.getRedoDescription()).toBe('Create note E4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should execute redo and restore forward state', async () => {
|
it('should execute redo and restore forward state', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const initialNoteCount = testRegion.getNotes().length
|
const initialNoteCount = testRegion.getNotes().length;
|
||||||
|
|
||||||
// Create, execute, and undo a command
|
// Create, execute, and undo a command
|
||||||
const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110) // G4
|
const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110); // G4
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createCommand)
|
commandHistory.executeCommand(createCommand);
|
||||||
commandHistory.undo()
|
commandHistory.undo();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify we're back to initial state
|
// Verify we're back to initial state
|
||||||
expect(testRegion.getNotes()).toHaveLength(initialNoteCount)
|
expect(testRegion.getNotes()).toHaveLength(initialNoteCount);
|
||||||
|
|
||||||
// Execute redo
|
// Execute redo
|
||||||
act(() => {
|
act(() => {
|
||||||
const redoSuccess = commandHistory.redo()
|
const redoSuccess = commandHistory.redo();
|
||||||
expect(redoSuccess).toBe(true)
|
expect(redoSuccess).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was restored to post-create state
|
// Verify core model was restored to post-create state
|
||||||
const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1)
|
expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||||
|
|
||||||
// Verify the specific note was recreated
|
// Verify the specific note was recreated
|
||||||
const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId())
|
const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId());
|
||||||
expect(recreatedNote).toBeDefined()
|
expect(recreatedNote).toBeDefined();
|
||||||
expect(recreatedNote!.getPitch()).toBe(67)
|
expect(recreatedNote!.getPitch()).toBe(67);
|
||||||
|
|
||||||
// Verify command history state
|
// Verify command history state
|
||||||
expect(commandHistory.canUndo()).toBe(true)
|
expect(commandHistory.canUndo()).toBe(true);
|
||||||
expect(commandHistory.canRedo()).toBe(false)
|
expect(commandHistory.canRedo()).toBe(false);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Multiple Command Integration', () => {
|
describe('Multiple Command Integration', () => {
|
||||||
it('should execute multiple commands and maintain history integrity', async () => {
|
it('should execute multiple commands and maintain history integrity', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
// Execute multiple note creation commands
|
// Execute multiple note creation commands
|
||||||
const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100) // C4
|
const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100); // C4
|
||||||
const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100) // E4
|
const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100); // E4
|
||||||
const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100) // G4
|
const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100); // G4
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(command1)
|
commandHistory.executeCommand(command1);
|
||||||
commandHistory.executeCommand(command2)
|
commandHistory.executeCommand(command2);
|
||||||
commandHistory.executeCommand(command3)
|
commandHistory.executeCommand(command3);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify all notes were created
|
// Verify all notes were created
|
||||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(region.getNotes()).toHaveLength(3)
|
expect(region.getNotes()).toHaveLength(3);
|
||||||
|
|
||||||
// Verify command history
|
// Verify command history
|
||||||
expect(commandHistory.canUndo()).toBe(true)
|
expect(commandHistory.canUndo()).toBe(true);
|
||||||
expect(commandHistory.getUndoDescription()).toBe('Create note G4')
|
expect(commandHistory.getUndoDescription()).toBe('Create note G4');
|
||||||
|
|
||||||
// Undo middle command by undoing twice
|
// Undo middle command by undoing twice
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.undo() // Remove G4
|
commandHistory.undo(); // Remove G4
|
||||||
commandHistory.undo() // Remove E4
|
commandHistory.undo(); // Remove E4
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify only first note remains
|
// Verify only first note remains
|
||||||
expect(region.getNotes()).toHaveLength(1)
|
expect(region.getNotes()).toHaveLength(1);
|
||||||
expect(region.getNotes()[0].getPitch()).toBe(60) // C4
|
expect(region.getNotes()[0].getPitch()).toBe(60); // C4
|
||||||
|
|
||||||
// Verify redo state
|
// Verify redo state
|
||||||
expect(commandHistory.canRedo()).toBe(true)
|
expect(commandHistory.canRedo()).toBe(true);
|
||||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4')
|
expect(commandHistory.getRedoDescription()).toBe('Create note E4');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle command execution with different command types', async () => {
|
it('should handle command execution with different command types', async () => {
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
const initialTrackCount = testProject.getTracks().length
|
const initialTrackCount = testProject.getTracks().length;
|
||||||
|
|
||||||
// Execute track addition command
|
// Execute track addition command
|
||||||
const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass')
|
const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(addTrackCommand)
|
commandHistory.executeCommand(addTrackCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify track was added to core model
|
// Verify track was added to core model
|
||||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1)
|
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
|
||||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack
|
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
|
||||||
expect(newTrack.getName()).toBe('Bass Track')
|
expect(newTrack.getName()).toBe('Bass Track');
|
||||||
expect(newTrack.getInstrument()).toBe('acoustic_bass')
|
expect(newTrack.getInstrument()).toBe('acoustic_bass');
|
||||||
|
|
||||||
// Add a note to the original region
|
// 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(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createNoteCommand)
|
commandHistory.executeCommand(createNoteCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify both commands are in history
|
// Verify both commands are in history
|
||||||
expect(commandHistory.canUndo()).toBe(true)
|
expect(commandHistory.canUndo()).toBe(true);
|
||||||
expect(commandHistory.getUndoDescription()).toBe('Create note C3')
|
expect(commandHistory.getUndoDescription()).toBe('Create note C3');
|
||||||
|
|
||||||
// Undo both commands
|
// Undo both commands
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.undo() // Undo note creation
|
commandHistory.undo(); // Undo note creation
|
||||||
commandHistory.undo() // Undo track addition
|
commandHistory.undo(); // Undo track addition
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify both operations were undone
|
// Verify both operations were undone
|
||||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount)
|
expect(testProject.getTracks()).toHaveLength(initialTrackCount);
|
||||||
const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(originalRegion.getNotes()).toHaveLength(0)
|
expect(originalRegion.getNotes()).toHaveLength(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Error Handling Integration', () => {
|
describe('Error Handling Integration', () => {
|
||||||
it('should handle command execution errors gracefully', async () => {
|
it('should handle command execution errors gracefully', async () => {
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
// Try to create note in non-existent region
|
// 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
|
// Execute command - should not throw but should not add to history
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(invalidCommand)
|
commandHistory.executeCommand(invalidCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify command was not added to history due to execution failure
|
// Verify command was not added to history due to execution failure
|
||||||
expect(commandHistory.canUndo()).toBe(false)
|
expect(commandHistory.canUndo()).toBe(false);
|
||||||
expect(commandHistory.getUndoDescription()).toBeNull()
|
expect(commandHistory.getUndoDescription()).toBeNull();
|
||||||
|
|
||||||
// Verify project state unchanged
|
// Verify project state unchanged
|
||||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||||
expect(region.getNotes()).toHaveLength(0)
|
expect(region.getNotes()).toHaveLength(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle undo failures gracefully', async () => {
|
it('should handle undo failures gracefully', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
|
|
||||||
// Create a command that will succeed initially
|
// 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(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createCommand)
|
commandHistory.executeCommand(createCommand);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Manually remove the region to cause undo to fail
|
// Manually remove the region to cause undo to fail
|
||||||
testTrack.removeRegion(testRegion.getId())
|
testTrack.removeRegion(testRegion.getId());
|
||||||
|
|
||||||
// Try to undo - should fail gracefully
|
// Try to undo - should fail gracefully
|
||||||
act(() => {
|
act(() => {
|
||||||
const undoSuccess = commandHistory.undo()
|
const undoSuccess = commandHistory.undo();
|
||||||
expect(undoSuccess).toBe(false)
|
expect(undoSuccess).toBe(false);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify command is still in undo stack after failed undo
|
// Verify command is still in undo stack after failed undo
|
||||||
expect(commandHistory.canUndo()).toBe(true)
|
expect(commandHistory.canUndo()).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Store Integration', () => {
|
describe('Store Integration', () => {
|
||||||
it('should keep store undo/redo state synchronized with command history', async () => {
|
it('should keep store undo/redo state synchronized with command history', async () => {
|
||||||
const regionId = testRegion.getId()
|
const regionId = testRegion.getId();
|
||||||
const commandHistory = KGCommandHistory.instance()
|
const commandHistory = KGCommandHistory.instance();
|
||||||
const { syncUndoRedoState } = useProjectStore.getState()
|
const { syncUndoRedoState } = useProjectStore.getState();
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
expect(useProjectStore.getState().canUndo).toBe(false)
|
expect(useProjectStore.getState().canUndo).toBe(false);
|
||||||
expect(useProjectStore.getState().canRedo).toBe(false)
|
expect(useProjectStore.getState().canRedo).toBe(false);
|
||||||
|
|
||||||
// Execute command
|
// Execute command
|
||||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
|
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.executeCommand(createCommand)
|
commandHistory.executeCommand(createCommand);
|
||||||
syncUndoRedoState() // Simulate store sync
|
syncUndoRedoState(); // Simulate store sync
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
let storeState = useProjectStore.getState()
|
let storeState = useProjectStore.getState();
|
||||||
expect(storeState.canUndo).toBe(true)
|
expect(storeState.canUndo).toBe(true);
|
||||||
expect(storeState.canRedo).toBe(false)
|
expect(storeState.canRedo).toBe(false);
|
||||||
expect(storeState.undoDescription).toBe('Create note C4')
|
expect(storeState.undoDescription).toBe('Create note C4');
|
||||||
expect(storeState.redoDescription).toBeNull()
|
expect(storeState.redoDescription).toBeNull();
|
||||||
|
|
||||||
// Execute undo
|
// Execute undo
|
||||||
act(() => {
|
act(() => {
|
||||||
commandHistory.undo()
|
commandHistory.undo();
|
||||||
syncUndoRedoState() // Simulate store sync
|
syncUndoRedoState(); // Simulate store sync
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated after undo
|
// Verify store state updated after undo
|
||||||
storeState = useProjectStore.getState()
|
storeState = useProjectStore.getState();
|
||||||
expect(storeState.canUndo).toBe(false)
|
expect(storeState.canUndo).toBe(false);
|
||||||
expect(storeState.canRedo).toBe(true)
|
expect(storeState.canRedo).toBe(true);
|
||||||
expect(storeState.undoDescription).toBeNull()
|
expect(storeState.undoDescription).toBeNull();
|
||||||
expect(storeState.redoDescription).toBe('Create note C4')
|
expect(storeState.redoDescription).toBe('Create note C4');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -2,445 +2,481 @@
|
|||||||
* Integration tests for project store synchronization with core models
|
* Integration tests for project store synchronization with core models
|
||||||
* Tests the critical data flow: Store Actions → Core Models → UI State Updates
|
* Tests the critical data flow: Store Actions → Core Models → UI State Updates
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { act, renderHook } from '@testing-library/react'
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
// Import core classes
|
// Import core classes
|
||||||
import { KGCore } from '../../../core/KGCore'
|
import { KGCore } from '../../../core/KGCore';
|
||||||
import { KGProject } from '../../../core/KGProject'
|
import { KGProject } from '../../../core/KGProject';
|
||||||
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack'
|
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack';
|
||||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote'
|
import { KGMidiNote } from '../../../core/midi/KGMidiNote';
|
||||||
|
import { KGMidiInput } from '../../../core/midi-input/KGMidiInput';
|
||||||
|
|
||||||
// Import store
|
// Import store
|
||||||
import { useProjectStore } from '../../../stores/projectStore'
|
import { useProjectStore } from '../../../stores/projectStore';
|
||||||
|
|
||||||
// Import test utilities and mocks
|
// Import test utilities and mocks
|
||||||
import '../../utils/setup-integration-tests'
|
import '../../utils/setup-integration-tests';
|
||||||
import { mockAudioInterface } from '../../mocks/audio-interface'
|
import { mockAudioInterface } from '../../mocks/audio-interface';
|
||||||
|
|
||||||
describe('Project Store Synchronization Integration Tests', () => {
|
describe('Project Store Synchronization Integration Tests', () => {
|
||||||
let testProject: KGProject
|
let testProject: KGProject;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Create a real project for testing
|
// Create a real project for testing
|
||||||
testProject = new KGProject('Sync Test Project')
|
testProject = new KGProject('Sync Test Project');
|
||||||
testProject.setBpm(120)
|
testProject.setBpm(120);
|
||||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 })
|
testProject.setTimeSignature({ numerator: 4, denominator: 4 });
|
||||||
testProject.setKeySignature('C major')
|
testProject.setKeySignature('C major');
|
||||||
testProject.setMaxBars(32)
|
testProject.setMaxBars(32);
|
||||||
|
|
||||||
// Initialize KGCore
|
// Initialize KGCore
|
||||||
const core = KGCore.instance()
|
const core = KGCore.instance();
|
||||||
await core.initialize()
|
await core.initialize();
|
||||||
core.setCurrentProject(testProject)
|
core.setCurrentProject(testProject);
|
||||||
|
|
||||||
// Reset store state
|
// Reset store state
|
||||||
const store = useProjectStore.getState()
|
const store = useProjectStore.getState();
|
||||||
await store.loadProject(testProject)
|
await store.loadProject(testProject);
|
||||||
})
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks();
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Project Properties Synchronization', () => {
|
describe('Project Properties Synchronization', () => {
|
||||||
it('should sync BPM changes between store and core model', async () => {
|
it('should sync BPM changes between store and core model', async () => {
|
||||||
const { setBpm } = useProjectStore.getState()
|
const { setBpm } = useProjectStore.getState();
|
||||||
const newBpm = 140
|
const newBpm = 140;
|
||||||
|
|
||||||
// Execute store action
|
// Execute store action
|
||||||
act(() => {
|
act(() => {
|
||||||
setBpm(newBpm)
|
setBpm(newBpm);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getBpm()).toBe(newBpm)
|
expect(testProject.getBpm()).toBe(newBpm);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.bpm).toBe(newBpm)
|
expect(storeState.bpm).toBe(newBpm);
|
||||||
|
|
||||||
// Verify CSS custom property was updated
|
// Verify CSS custom property was updated
|
||||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||||
expect(cssValue).toBeTruthy() // CSS should be updated by store action
|
expect(cssValue).toBeTruthy(); // CSS should be updated by store action
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should sync time signature changes and update CSS properties', async () => {
|
it('should sync time signature changes and update CSS properties', async () => {
|
||||||
const { setTimeSignature } = useProjectStore.getState()
|
const { setTimeSignature } = useProjectStore.getState();
|
||||||
const newTimeSignature = { numerator: 3, denominator: 4 }
|
const newTimeSignature = { numerator: 3, denominator: 4 };
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
setTimeSignature(newTimeSignature)
|
setTimeSignature(newTimeSignature);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getTimeSignature()).toEqual(newTimeSignature)
|
expect(testProject.getTimeSignature()).toEqual(newTimeSignature);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.timeSignature).toEqual(newTimeSignature)
|
expect(storeState.timeSignature).toEqual(newTimeSignature);
|
||||||
|
|
||||||
// Verify CSS custom property was updated for UI calculations
|
// Verify CSS custom property was updated for UI calculations
|
||||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||||
expect(cssValue.trim()).toBe('3')
|
expect(cssValue.trim()).toBe('3');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should sync max bars changes and update layout CSS', async () => {
|
it('should sync max bars changes and update layout CSS', async () => {
|
||||||
const { setMaxBars } = useProjectStore.getState()
|
const { setMaxBars } = useProjectStore.getState();
|
||||||
const newMaxBars = 64
|
const newMaxBars = 64;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
setMaxBars(newMaxBars)
|
setMaxBars(newMaxBars);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getMaxBars()).toBe(newMaxBars)
|
expect(testProject.getMaxBars()).toBe(newMaxBars);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.maxBars).toBe(newMaxBars)
|
expect(storeState.maxBars).toBe(newMaxBars);
|
||||||
|
|
||||||
// Verify CSS custom property was updated for layout
|
// Verify CSS custom property was updated for layout
|
||||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars')
|
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
|
||||||
expect(cssValue.trim()).toBe('64')
|
expect(cssValue.trim()).toBe('64');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should sync key signature changes', async () => {
|
it('should sync key signature changes', async () => {
|
||||||
const { setKeySignature } = useProjectStore.getState()
|
const { setKeySignature } = useProjectStore.getState();
|
||||||
const newKeySignature = 'G major'
|
const newKeySignature = 'G major';
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
setKeySignature(newKeySignature)
|
setKeySignature(newKeySignature);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getKeySignature()).toBe(newKeySignature)
|
expect(testProject.getKeySignature()).toBe(newKeySignature);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.keySignature).toBe(newKeySignature)
|
expect(storeState.keySignature).toBe(newKeySignature);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Track Management Synchronization', () => {
|
describe('Track Management Synchronization', () => {
|
||||||
it('should sync track addition with core model and audio interface', async () => {
|
it('should sync track addition with core model and audio interface', async () => {
|
||||||
const { addTrack } = useProjectStore.getState()
|
const { addTrack } = useProjectStore.getState();
|
||||||
const initialTrackCount = testProject.getTracks().length
|
const initialTrackCount = testProject.getTracks().length;
|
||||||
|
|
||||||
// Execute store action
|
// Execute store action
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await addTrack()
|
await addTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1)
|
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
|
||||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack
|
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
|
||||||
expect(newTrack).toBeInstanceOf(KGMidiTrack)
|
expect(newTrack).toBeInstanceOf(KGMidiTrack);
|
||||||
expect(newTrack.getName()).toContain('Track')
|
expect(newTrack.getName()).toContain('Track');
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.tracks).toHaveLength(initialTrackCount + 1)
|
expect(storeState.tracks).toHaveLength(initialTrackCount + 1);
|
||||||
|
|
||||||
// Verify audio interface was notified
|
// 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 () => {
|
it('should sync track removal with core model and audio interface', async () => {
|
||||||
// First add a track
|
// First add a track
|
||||||
const { addTrack, removeTrack } = useProjectStore.getState()
|
const { addTrack, removeTrack } = useProjectStore.getState();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await addTrack()
|
await addTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
const trackCountAfterAdd = testProject.getTracks().length
|
const trackCountAfterAdd = testProject.getTracks().length;
|
||||||
const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1]
|
const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1];
|
||||||
|
|
||||||
// Remove the track
|
// Remove the track
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await removeTrack(trackCountAfterAdd - 1) // Remove last track
|
await removeTrack(trackCountAfterAdd - 1); // Remove last track
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1)
|
expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1)
|
expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1);
|
||||||
|
|
||||||
// Verify audio interface was notified
|
// 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 () => {
|
it('should sync track instrument changes with audio interface', async () => {
|
||||||
// Add a track first
|
// Add a track first
|
||||||
const { addTrack, setTrackInstrument } = useProjectStore.getState()
|
const { addTrack, setTrackInstrument } = useProjectStore.getState();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await addTrack()
|
await addTrack();
|
||||||
})
|
});
|
||||||
|
|
||||||
const trackIndex = testProject.getTracks().length - 1
|
const trackIndex = testProject.getTracks().length - 1;
|
||||||
const track = testProject.getTracks()[trackIndex] as KGMidiTrack
|
const track = testProject.getTracks()[trackIndex] as KGMidiTrack;
|
||||||
const newInstrument: InstrumentType = 'electric_bass'
|
const newInstrument: InstrumentType = 'electric_bass';
|
||||||
|
|
||||||
// Change track instrument
|
// Change track instrument
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await setTrackInstrument(trackIndex, newInstrument)
|
await setTrackInstrument(trackIndex, newInstrument);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model was updated
|
// Verify core model was updated
|
||||||
expect(track.getInstrument()).toBe(newInstrument)
|
expect(track.getInstrument()).toBe(newInstrument);
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack
|
const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack;
|
||||||
expect(storeTrack.getInstrument()).toBe(newInstrument)
|
expect(storeTrack.getInstrument()).toBe(newInstrument);
|
||||||
|
|
||||||
// Verify audio interface was notified
|
// 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 () => {
|
it('should sync track reordering with core model', async () => {
|
||||||
const { addTrack, reorderTracks } = useProjectStore.getState()
|
const { addTrack, reorderTracks } = useProjectStore.getState();
|
||||||
|
|
||||||
// Add two tracks
|
// Add two tracks
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await addTrack() // Track at index 0
|
await addTrack(); // Track at index 0
|
||||||
await addTrack() // Track at index 1
|
await addTrack(); // Track at index 1
|
||||||
})
|
});
|
||||||
|
|
||||||
const track0Before = testProject.getTracks()[0]
|
const track0Before = testProject.getTracks()[0];
|
||||||
const track1Before = testProject.getTracks()[1]
|
const track1Before = testProject.getTracks()[1];
|
||||||
|
|
||||||
// Reorder tracks (move track 0 to position 1)
|
// Reorder tracks (move track 0 to position 1)
|
||||||
act(() => {
|
act(() => {
|
||||||
reorderTracks(0, 1)
|
reorderTracks(0, 1);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify core model track order changed
|
// Verify core model track order changed
|
||||||
const track0After = testProject.getTracks()[0]
|
const track0After = testProject.getTracks()[0];
|
||||||
const track1After = testProject.getTracks()[1]
|
const track1After = testProject.getTracks()[1];
|
||||||
|
|
||||||
expect(track0After.getId()).toBe(track1Before.getId())
|
expect(track0After.getId()).toBe(track1Before.getId());
|
||||||
expect(track1After.getId()).toBe(track0Before.getId())
|
expect(track1After.getId()).toBe(track0Before.getId());
|
||||||
|
|
||||||
// Verify store state reflects change
|
// Verify store state reflects change
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.tracks[0].getId()).toBe(track1Before.getId())
|
expect(storeState.tracks[0].getId()).toBe(track1Before.getId());
|
||||||
expect(storeState.tracks[1].getId()).toBe(track0Before.getId())
|
expect(storeState.tracks[1].getId()).toBe(track0Before.getId());
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Playback State Synchronization', () => {
|
describe('Playback State Synchronization', () => {
|
||||||
it('should sync playhead position with formatted time string', async () => {
|
it('should sync playhead position with formatted time string', async () => {
|
||||||
const { setPlayheadPosition } = useProjectStore.getState()
|
const { setPlayheadPosition } = useProjectStore.getState();
|
||||||
const newPosition = 8.5 // beats
|
const newPosition = 8.5; // beats
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
setPlayheadPosition(newPosition)
|
setPlayheadPosition(newPosition);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.playheadPosition).toBe(newPosition)
|
expect(storeState.playheadPosition).toBe(newPosition);
|
||||||
|
|
||||||
// Verify formatted time string was updated
|
// Verify formatted time string was updated
|
||||||
expect(storeState.currentTime).toBeDefined()
|
expect(storeState.currentTime).toBeDefined();
|
||||||
expect(storeState.currentTime).toContain('|') // Should contain BBB:B | mm:ss:mmm format
|
expect(storeState.currentTime).toContain('|'); // Should contain BBB:B | mm:ss:mmm format
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should sync playback state changes', async () => {
|
it('should sync playback state changes', async () => {
|
||||||
const { startPlaying, stopPlaying } = useProjectStore.getState()
|
const { startPlaying, stopPlaying } = useProjectStore.getState();
|
||||||
|
|
||||||
// Start playing
|
// Start playing
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await startPlaying()
|
await startPlaying();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
let storeState = useProjectStore.getState()
|
let storeState = useProjectStore.getState();
|
||||||
expect(storeState.isPlaying).toBe(true)
|
expect(storeState.isPlaying).toBe(true);
|
||||||
|
|
||||||
// Verify audio interface was called
|
// Verify audio interface was called
|
||||||
expect(mockAudioInterface.startPlayback).toHaveBeenCalled()
|
expect(mockAudioInterface.startPlayback).toHaveBeenCalled();
|
||||||
|
|
||||||
// Stop playing
|
// Stop playing
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await stopPlaying()
|
await stopPlaying();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
storeState = useProjectStore.getState()
|
storeState = useProjectStore.getState();
|
||||||
expect(storeState.isPlaying).toBe(false)
|
expect(storeState.isPlaying).toBe(false);
|
||||||
|
|
||||||
// Verify audio interface was called
|
// 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]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await useProjectStore.getState().loadProject(testProject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const core = KGCore.instance();
|
||||||
|
const startPlayingSpy = vi.spyOn(core, 'startPlaying').mockResolvedValue(undefined);
|
||||||
|
const recordingCallbacksSpy = vi.spyOn(KGMidiInput.instance(), 'setRecordingCallbacks');
|
||||||
|
const { setActiveRegionId, setPlayheadPosition, startRecording } = useProjectStore.getState();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setActiveRegionId(testRegion.getId());
|
||||||
|
setPlayheadPosition(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await startRecording();
|
||||||
|
});
|
||||||
|
|
||||||
|
const storeState = useProjectStore.getState();
|
||||||
|
expect(storeState.playheadPosition).toBe(12);
|
||||||
|
expect(storeState.recordingOriginalPlayhead).toBe(18);
|
||||||
|
expect(storeState.isRecording).toBe(true);
|
||||||
|
expect(storeState.isPlaying).toBe(true);
|
||||||
|
expect(recordingCallbacksSpy).toHaveBeenCalled();
|
||||||
|
expect(startPlayingSpy).toHaveBeenCalledWith({ preserveLoopPreroll: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Selection State Synchronization', () => {
|
describe('Selection State Synchronization', () => {
|
||||||
it('should sync selection state with core piano roll state', async () => {
|
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
|
// Add a track and region for testing
|
||||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||||
const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
|
const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
|
||||||
testTrack.addRegion(testRegion)
|
testTrack.addRegion(testRegion);
|
||||||
testProject.setTracks([...testProject.getTracks(), testTrack])
|
testProject.setTracks([...testProject.getTracks(), testTrack]);
|
||||||
|
|
||||||
// Set active region
|
// Set active region
|
||||||
act(() => {
|
act(() => {
|
||||||
setActiveRegionId(testRegion.getId())
|
setActiveRegionId(testRegion.getId());
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
let storeState = useProjectStore.getState()
|
let storeState = useProjectStore.getState();
|
||||||
expect(storeState.activeRegionId).toBe(testRegion.getId())
|
expect(storeState.activeRegionId).toBe(testRegion.getId());
|
||||||
|
|
||||||
// Simulate core selection changes and sync
|
// Simulate core selection changes and sync
|
||||||
act(() => {
|
act(() => {
|
||||||
syncSelectionFromCore()
|
syncSelectionFromCore();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store selection state is synchronized
|
// Verify store selection state is synchronized
|
||||||
storeState = useProjectStore.getState()
|
storeState = useProjectStore.getState();
|
||||||
expect(storeState.selectedNoteIds).toBeDefined()
|
expect(storeState.selectedNoteIds).toBeDefined();
|
||||||
expect(storeState.selectedRegionIds).toBeDefined()
|
expect(storeState.selectedRegionIds).toBeDefined();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should clear all selections and sync state', async () => {
|
it('should clear all selections and sync state', async () => {
|
||||||
const { clearAllSelections, setSelectedTrack } = useProjectStore.getState()
|
const { clearAllSelections, setSelectedTrack } = useProjectStore.getState();
|
||||||
|
|
||||||
// Set some initial selection state
|
// Set some initial selection state
|
||||||
act(() => {
|
act(() => {
|
||||||
setSelectedTrack('test-track-id')
|
setSelectedTrack('test-track-id');
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify selection was set
|
// Verify selection was set
|
||||||
let storeState = useProjectStore.getState()
|
let storeState = useProjectStore.getState();
|
||||||
expect(storeState.selectedTrackId).toBe('test-track-id')
|
expect(storeState.selectedTrackId).toBe('test-track-id');
|
||||||
|
|
||||||
// Clear all selections
|
// Clear all selections
|
||||||
act(() => {
|
act(() => {
|
||||||
clearAllSelections()
|
clearAllSelections();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify all selections were cleared
|
// Verify all selections were cleared
|
||||||
storeState = useProjectStore.getState()
|
storeState = useProjectStore.getState();
|
||||||
expect(storeState.selectedTrackId).toBeNull()
|
expect(storeState.selectedTrackId).toBeNull();
|
||||||
expect(storeState.selectedNoteIds).toEqual([])
|
expect(storeState.selectedNoteIds).toEqual([]);
|
||||||
expect(storeState.selectedRegionIds).toEqual([])
|
expect(storeState.selectedRegionIds).toEqual([]);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Piano Roll State Integration', () => {
|
describe('Piano Roll State Integration', () => {
|
||||||
it('should sync piano roll visibility and active region', async () => {
|
it('should sync piano roll visibility and active region', async () => {
|
||||||
const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState()
|
const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||||
|
|
||||||
// Add test region
|
// Add test region
|
||||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||||
const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16)
|
const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16);
|
||||||
testTrack.addRegion(testRegion)
|
testTrack.addRegion(testRegion);
|
||||||
testProject.setTracks([...testProject.getTracks(), testTrack])
|
testProject.setTracks([...testProject.getTracks(), testTrack]);
|
||||||
|
|
||||||
// Show piano roll with active region
|
// Show piano roll with active region
|
||||||
act(() => {
|
act(() => {
|
||||||
setActiveRegionId(testRegion.getId())
|
setActiveRegionId(testRegion.getId());
|
||||||
setShowPianoRoll(true)
|
setShowPianoRoll(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state updated
|
// Verify store state updated
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.showPianoRoll).toBe(true)
|
expect(storeState.showPianoRoll).toBe(true);
|
||||||
expect(storeState.activeRegionId).toBe(testRegion.getId())
|
expect(storeState.activeRegionId).toBe(testRegion.getId());
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Error Handling and Edge Cases', () => {
|
describe('Error Handling and Edge Cases', () => {
|
||||||
it('should handle invalid track operations gracefully', async () => {
|
it('should handle invalid track operations gracefully', async () => {
|
||||||
const { removeTrack } = useProjectStore.getState()
|
const { removeTrack } = useProjectStore.getState();
|
||||||
const initialTrackCount = testProject.getTracks().length
|
const initialTrackCount = testProject.getTracks().length;
|
||||||
|
|
||||||
// Try to remove non-existent track
|
// Try to remove non-existent track
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await removeTrack(999) // Invalid index
|
await removeTrack(999); // Invalid index
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify project state unchanged
|
// Verify project state unchanged
|
||||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount)
|
expect(testProject.getTracks()).toHaveLength(initialTrackCount);
|
||||||
|
|
||||||
// Verify store state unchanged
|
// Verify store state unchanged
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.tracks).toHaveLength(initialTrackCount)
|
expect(storeState.tracks).toHaveLength(initialTrackCount);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle concurrent state updates correctly', async () => {
|
it('should handle concurrent state updates correctly', async () => {
|
||||||
const { setBpm, setMaxBars } = useProjectStore.getState()
|
const { setBpm, setMaxBars } = useProjectStore.getState();
|
||||||
|
|
||||||
// Execute multiple state updates concurrently
|
// Execute multiple state updates concurrently
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
setBpm(140)
|
setBpm(140);
|
||||||
setMaxBars(64)
|
setMaxBars(64);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify both updates were applied to core model
|
// Verify both updates were applied to core model
|
||||||
expect(testProject.getBpm()).toBe(140)
|
expect(testProject.getBpm()).toBe(140);
|
||||||
expect(testProject.getMaxBars()).toBe(64)
|
expect(testProject.getMaxBars()).toBe(64);
|
||||||
|
|
||||||
// Verify store state is consistent
|
// Verify store state is consistent
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.bpm).toBe(140)
|
expect(storeState.bpm).toBe(140);
|
||||||
expect(storeState.maxBars).toBe(64)
|
expect(storeState.maxBars).toBe(64);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('Project Loading Integration', () => {
|
describe('Project Loading Integration', () => {
|
||||||
it('should completely sync store state when loading new project', async () => {
|
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
|
// Create a new project with specific properties
|
||||||
const newProject = new KGProject('New Loaded Project')
|
const newProject = new KGProject('New Loaded Project');
|
||||||
newProject.setBpm(160)
|
newProject.setBpm(160);
|
||||||
newProject.setTimeSignature({ numerator: 6, denominator: 8 })
|
newProject.setTimeSignature({ numerator: 6, denominator: 8 });
|
||||||
newProject.setKeySignature('D major')
|
newProject.setKeySignature('D major');
|
||||||
newProject.setMaxBars(48)
|
newProject.setMaxBars(48);
|
||||||
|
|
||||||
// Add a track with region and notes
|
// Add a track with region and notes
|
||||||
const track = new KGMidiTrack('Loaded Track', 0, 'violin')
|
const track = new KGMidiTrack('Loaded Track', 0, 'violin');
|
||||||
const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8)
|
const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8);
|
||||||
const note = new KGMidiNote('test-note', 0, 1, 64, 100)
|
const note = new KGMidiNote('test-note', 0, 1, 64, 100);
|
||||||
|
|
||||||
region.addNote(note)
|
region.addNote(note);
|
||||||
track.addRegion(region)
|
track.addRegion(region);
|
||||||
newProject.setTracks([track])
|
newProject.setTracks([track]);
|
||||||
|
|
||||||
// Load the new project
|
// Load the new project
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await loadProject(newProject)
|
await loadProject(newProject);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify store state completely matches new project
|
// Verify store state completely matches new project
|
||||||
const storeState = useProjectStore.getState()
|
const storeState = useProjectStore.getState();
|
||||||
expect(storeState.projectName).toBe('New Loaded Project')
|
expect(storeState.projectName).toBe('New Loaded Project');
|
||||||
expect(storeState.bpm).toBe(160)
|
expect(storeState.bpm).toBe(160);
|
||||||
expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 })
|
expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 });
|
||||||
expect(storeState.keySignature).toBe('D major')
|
expect(storeState.keySignature).toBe('D major');
|
||||||
expect(storeState.maxBars).toBe(48)
|
expect(storeState.maxBars).toBe(48);
|
||||||
expect(storeState.tracks).toHaveLength(1)
|
expect(storeState.tracks).toHaveLength(1);
|
||||||
|
|
||||||
// Verify core model is updated
|
// Verify core model is updated
|
||||||
const core = KGCore.instance()
|
const core = KGCore.instance();
|
||||||
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project')
|
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project');
|
||||||
|
|
||||||
// Verify CSS properties were updated
|
// Verify CSS properties were updated
|
||||||
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||||
expect(timeSignatureCSS.trim()).toBe('6')
|
expect(timeSignatureCSS.trim()).toBe('6');
|
||||||
|
|
||||||
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars')
|
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
|
||||||
expect(maxBarsCSS.trim()).toBe('48')
|
expect(maxBarsCSS.trim()).toBe('48');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { plainToInstance } from 'class-transformer'
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil'
|
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil';
|
||||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack'
|
import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
|
||||||
import { KGCore } from '../../../core/KGCore'
|
import { KGCore } from '../../../core/KGCore';
|
||||||
import { KGProject } from '../../../core/KGProject'
|
import { KGProject } from '../../../core/KGProject';
|
||||||
|
|
||||||
// Import the test fixture
|
// 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)
|
// Helper function to load project using real class-transformer deserialization (same as UI)
|
||||||
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
|
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
|
||||||
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
|
// 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)
|
// Handle case where plainToInstance might return an array (same as UI)
|
||||||
const deserializedProject = Array.isArray(deserializedResult)
|
const deserializedProject = Array.isArray(deserializedResult)
|
||||||
? deserializedResult[0] || null
|
? deserializedResult[0] || null
|
||||||
: deserializedResult
|
: deserializedResult;
|
||||||
|
|
||||||
if (!deserializedProject) {
|
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', () => {
|
describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
|
||||||
let joyProject: KGProject
|
let joyProject: KGProject;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport
|
// Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport
|
||||||
// First parse as JSON (simulating file.text() -> JSON.parse())
|
// First parse as JSON (simulating file.text() -> JSON.parse())
|
||||||
const fileContent = JSON.stringify(joyProjectData)
|
const fileContent = JSON.stringify(joyProjectData);
|
||||||
const projectData = JSON.parse(fileContent)
|
const projectData = JSON.parse(fileContent);
|
||||||
|
|
||||||
// Then deserialize using class-transformer (same as UI)
|
// 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)
|
// Mock KGCore to return the loaded project (same as production flow)
|
||||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||||
getCurrentProject: () => joyProject
|
getCurrentProject: () => joyProject
|
||||||
} as unknown as KGCore)
|
} as unknown as KGCore);
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('convertRegionToABCNotation with real project data', () => {
|
describe('convertRegionToABCNotation with real project data', () => {
|
||||||
it('should convert joy project melody region to exact ABC notation', () => {
|
it('should convert joy project melody region to exact ABC notation', () => {
|
||||||
// Get the melody track and region using real project structure
|
// Get the melody track and region using real project structure
|
||||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||||
expect(melodyTrack.getName()).toBe('Melody')
|
expect(melodyTrack.getName()).toBe('Melody');
|
||||||
expect(melodyTrack.getRegions()).toHaveLength(1)
|
expect(melodyTrack.getRegions()).toHaveLength(1);
|
||||||
|
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
expect(melodyRegion.getName()).toBe('Melody Region 1')
|
expect(melodyRegion.getName()).toBe('Melody Region 1');
|
||||||
expect(melodyRegion.getNotes()).toHaveLength(30) // Verify we have all 30 notes
|
expect(melodyRegion.getNotes()).toHaveLength(30); // Verify we have all 30 notes
|
||||||
|
|
||||||
// Convert to ABC notation using real region data
|
// 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)
|
// Expected ABC notation output (exactly as provided)
|
||||||
const expectedABCNotation = `X:1
|
const expectedABCNotation = `X:1
|
||||||
@@ -65,161 +65,161 @@ M:4/4
|
|||||||
L:1/4
|
L:1/4
|
||||||
Q:1/4=125
|
Q:1/4=125
|
||||||
K:C
|
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
|
// Verify exact match
|
||||||
expect(result).toBe(expectedABCNotation)
|
expect(result).toBe(expectedABCNotation);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle empty pad chord region from real project', () => {
|
it('should handle empty pad chord region from real project', () => {
|
||||||
// Get the pad chord track and region
|
// Get the pad chord track and region
|
||||||
const padTrack = joyProject.getTracks()[1] as KGMidiTrack
|
const padTrack = joyProject.getTracks()[1] as KGMidiTrack;
|
||||||
expect(padTrack.getName()).toBe('Pad Chord')
|
expect(padTrack.getName()).toBe('Pad Chord');
|
||||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
|
expect(padTrack.getInstrument()).toBe('pad_1_new_age');
|
||||||
|
|
||||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
|
const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
|
||||||
expect(padRegion.getName()).toBe('Pad Chord Region 1')
|
expect(padRegion.getName()).toBe('Pad Chord Region 1');
|
||||||
expect(padRegion.getNotes()).toHaveLength(0) // Empty region
|
expect(padRegion.getNotes()).toHaveLength(0); // Empty region
|
||||||
|
|
||||||
// Convert empty region to ABC notation
|
// 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
|
// Verify it contains rest notation and proper headers
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined();
|
||||||
expect(result).toContain('T:Pad Chord Region 1')
|
expect(result).toContain('T:Pad Chord Region 1');
|
||||||
expect(result).toContain('M:4/4')
|
expect(result).toContain('M:4/4');
|
||||||
expect(result).toContain('Q:1/4=125')
|
expect(result).toContain('Q:1/4=125');
|
||||||
expect(result).toContain('K:C')
|
expect(result).toContain('K:C');
|
||||||
expect(result).toContain('z') // Should contain rest notation
|
expect(result).toContain('z'); // Should contain rest notation
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should use correct project settings from real project data', () => {
|
it('should use correct project settings from real project data', () => {
|
||||||
// Verify project settings are loaded correctly
|
// Verify project settings are loaded correctly
|
||||||
expect(joyProject.getName()).toBe('joy')
|
expect(joyProject.getName()).toBe('joy');
|
||||||
expect(joyProject.getBpm()).toBe(125)
|
expect(joyProject.getBpm()).toBe(125);
|
||||||
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 })
|
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 });
|
||||||
expect(joyProject.getKeySignature()).toBe('C major')
|
expect(joyProject.getKeySignature()).toBe('C major');
|
||||||
expect(joyProject.getMaxBars()).toBe(32)
|
expect(joyProject.getMaxBars()).toBe(32);
|
||||||
|
|
||||||
// These settings should be reflected in ABC notation headers
|
// These settings should be reflected in ABC notation headers
|
||||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
|
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||||
|
|
||||||
expect(result).toContain('Q:1/4=125') // BPM
|
expect(result).toContain('Q:1/4=125'); // BPM
|
||||||
expect(result).toContain('M:4/4') // Time signature
|
expect(result).toContain('M:4/4'); // Time signature
|
||||||
expect(result).toContain('K:C') // Key signature
|
expect(result).toContain('K:C'); // Key signature
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle real note timing and pitches correctly', () => {
|
it('should handle real note timing and pitches correctly', () => {
|
||||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
const notes = melodyRegion.getNotes()
|
const notes = melodyRegion.getNotes();
|
||||||
|
|
||||||
// Verify some key notes from the real data
|
// Verify some key notes from the real data
|
||||||
expect(notes[0].getPitch()).toBe(64) // First note is E (64)
|
expect(notes[0].getPitch()).toBe(64); // First note is E (64)
|
||||||
expect(notes[0].getStartBeat()).toBe(0)
|
expect(notes[0].getStartBeat()).toBe(0);
|
||||||
expect(notes[0].getEndBeat()).toBe(1)
|
expect(notes[0].getEndBeat()).toBe(1);
|
||||||
|
|
||||||
expect(notes[3].getPitch()).toBe(67) // Fourth note is G (67)
|
expect(notes[3].getPitch()).toBe(67); // Fourth note is G (67)
|
||||||
expect(notes[3].getStartBeat()).toBe(3)
|
expect(notes[3].getStartBeat()).toBe(3);
|
||||||
expect(notes[3].getEndBeat()).toBe(4)
|
expect(notes[3].getEndBeat()).toBe(4);
|
||||||
|
|
||||||
// Verify fractional timing note (beat 12-13.5)
|
// Verify fractional timing note (beat 12-13.5)
|
||||||
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5)
|
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5);
|
||||||
expect(fractionalNote).toBeDefined()
|
expect(fractionalNote).toBeDefined();
|
||||||
expect(fractionalNote!.getPitch()).toBe(64) // E
|
expect(fractionalNote!.getPitch()).toBe(64); // E
|
||||||
expect(fractionalNote!.getStartBeat()).toBe(12)
|
expect(fractionalNote!.getStartBeat()).toBe(12);
|
||||||
|
|
||||||
// Convert and verify these real timings are reflected in ABC notation
|
// Convert and verify these real timings are reflected in ABC notation
|
||||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
|
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||||
expect(result).toContain('E3/2 D1/2') // Fractional timing should appear in ABC
|
expect(result).toContain('E3/2 D1/2'); // Fractional timing should appear in ABC
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle multiple tracks with different instruments', () => {
|
it('should handle multiple tracks with different instruments', () => {
|
||||||
const tracks = joyProject.getTracks()
|
const tracks = joyProject.getTracks();
|
||||||
expect(tracks).toHaveLength(2)
|
expect(tracks).toHaveLength(2);
|
||||||
|
|
||||||
// Verify track properties from real project
|
// Verify track properties from real project
|
||||||
const melodyTrack = tracks[0] as KGMidiTrack
|
const melodyTrack = tracks[0] as KGMidiTrack;
|
||||||
expect(melodyTrack.getName()).toBe('Melody')
|
expect(melodyTrack.getName()).toBe('Melody');
|
||||||
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano')
|
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano');
|
||||||
expect(melodyTrack.getVolume()).toBe(0.8)
|
expect(melodyTrack.getVolume()).toBe(0.8);
|
||||||
|
|
||||||
const padTrack = tracks[1] as KGMidiTrack
|
const padTrack = tracks[1] as KGMidiTrack;
|
||||||
expect(padTrack.getName()).toBe('Pad Chord')
|
expect(padTrack.getName()).toBe('Pad Chord');
|
||||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
|
expect(padTrack.getInstrument()).toBe('pad_1_new_age');
|
||||||
expect(padTrack.getVolume()).toBe(1)
|
expect(padTrack.getVolume()).toBe(1);
|
||||||
|
|
||||||
// Both tracks should be processable for ABC notation
|
// Both tracks should be processable for ABC notation
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
|
const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
|
||||||
|
|
||||||
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32)
|
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||||
const padABC = convertRegionToABCNotation(padRegion, 0, 32)
|
const padABC = convertRegionToABCNotation(padRegion, 0, 32);
|
||||||
|
|
||||||
expect(melodyABC).toContain('T:Melody Region 1')
|
expect(melodyABC).toContain('T:Melody Region 1');
|
||||||
expect(padABC).toContain('T:Pad Chord Region 1')
|
expect(padABC).toContain('T:Pad Chord Region 1');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should verify complete deserialization hierarchy', () => {
|
it('should verify complete deserialization hierarchy', () => {
|
||||||
// Test that class-transformer properly restored the entire object 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 => {
|
tracks.forEach(track => {
|
||||||
expect(track).toBeInstanceOf(KGMidiTrack)
|
expect(track).toBeInstanceOf(KGMidiTrack);
|
||||||
|
|
||||||
const regions = track.getRegions()
|
const regions = track.getRegions();
|
||||||
regions.forEach(region => {
|
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.forEach(note => {
|
||||||
// Notes should have proper methods and properties
|
// Notes should have proper methods and properties
|
||||||
expect(typeof note.getId()).toBe('string')
|
expect(typeof note.getId()).toBe('string');
|
||||||
expect(typeof note.getPitch()).toBe('number')
|
expect(typeof note.getPitch()).toBe('number');
|
||||||
expect(typeof note.getStartBeat()).toBe('number')
|
expect(typeof note.getStartBeat()).toBe('number');
|
||||||
expect(typeof note.getEndBeat()).toBe('number')
|
expect(typeof note.getEndBeat()).toBe('number');
|
||||||
expect(typeof note.getVelocity()).toBe('number')
|
expect(typeof note.getVelocity()).toBe('number');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Verify type identifiers are preserved
|
// Verify type identifiers are preserved
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack')
|
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases with real project data', () => {
|
describe('edge cases with real project data', () => {
|
||||||
it('should handle partial region conversion', () => {
|
it('should handle partial region conversion', () => {
|
||||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
|
|
||||||
// Test converting only first 8 beats (2 bars)
|
// 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).toBeDefined();
|
||||||
expect(result).toContain('T:Melody Region 1')
|
expect(result).toContain('T:Melody Region 1');
|
||||||
// Should only contain the first 2 bars of music
|
// 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', () => {
|
it('should handle mid-region start point', () => {
|
||||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||||
|
|
||||||
// Test converting from beat 16 to 24 (second half of melody)
|
// 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).toBeDefined();
|
||||||
expect(result).toContain('T:Melody Region 1')
|
expect(result).toContain('T:Melody Region 1');
|
||||||
// Should start from the second repetition
|
// Should start from the second repetition
|
||||||
const lines = result.split('\n')
|
const lines = result.split('\n');
|
||||||
const musicLine = lines[lines.length - 1] // Last line contains the music
|
const musicLine = lines[lines.length - 1]; // Last line contains the music
|
||||||
expect(musicLine).toBeTruthy()
|
expect(musicLine).toBeTruthy();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Mock implementation of KGAudioInterface for integration tests
|
* Mock implementation of KGAudioInterface for integration tests
|
||||||
* Provides interface compatibility while avoiding actual audio operations
|
* Provides interface compatibility while avoiding actual audio operations
|
||||||
*/
|
*/
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
export const mockAudioInterface = {
|
export const mockAudioInterface = {
|
||||||
// Audio context management
|
// Audio context management
|
||||||
@@ -34,7 +34,7 @@ export const mockAudioInterface = {
|
|||||||
|
|
||||||
// Singleton pattern
|
// Singleton pattern
|
||||||
getInstance: vi.fn().mockReturnThis(),
|
getInstance: vi.fn().mockReturnThis(),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Mock the class constructor
|
// Mock the class constructor
|
||||||
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface)
|
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface);
|
||||||
@@ -2,54 +2,57 @@
|
|||||||
* Mock implementation of IndexedDB for integration tests
|
* Mock implementation of IndexedDB for integration tests
|
||||||
* Provides in-memory storage that mimics IndexedDB interface
|
* Provides in-memory storage that mimics IndexedDB interface
|
||||||
*/
|
*/
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
// In-memory storage for tests
|
// In-memory storage for tests
|
||||||
const mockStorage = new Map<string, any>()
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mockStorage = new Map<string, any>();
|
||||||
|
|
||||||
export const mockIndexedDB = {
|
export const mockIndexedDB = {
|
||||||
openDB: vi.fn().mockImplementation(() => {
|
openDB: vi.fn().mockImplementation(() => {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => {
|
put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => {
|
||||||
const actualKey = key || data.id || 'default'
|
const actualKey = key || data.id || 'default';
|
||||||
mockStorage.set(`${storeName}:${actualKey}`, data)
|
mockStorage.set(`${storeName}:${actualKey}`, data);
|
||||||
return Promise.resolve(actualKey)
|
return Promise.resolve(actualKey);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
get: vi.fn().mockImplementation((storeName: string, key: string) => {
|
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) => {
|
getAll: vi.fn().mockImplementation((storeName: string) => {
|
||||||
const results: any[] = []
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const results: any[] = [];
|
||||||
for (const [key, value] of mockStorage.entries()) {
|
for (const [key, value] of mockStorage.entries()) {
|
||||||
if (key.startsWith(`${storeName}:`)) {
|
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) => {
|
delete: vi.fn().mockImplementation((storeName: string, key: string) => {
|
||||||
mockStorage.delete(`${storeName}:${key}`)
|
mockStorage.delete(`${storeName}:${key}`);
|
||||||
return Promise.resolve()
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
clear: vi.fn().mockImplementation((storeName: string) => {
|
clear: vi.fn().mockImplementation((storeName: string) => {
|
||||||
for (const key of mockStorage.keys()) {
|
for (const key of mockStorage.keys()) {
|
||||||
if (key.startsWith(`${storeName}:`)) {
|
if (key.startsWith(`${storeName}:`)) {
|
||||||
mockStorage.delete(key)
|
mockStorage.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
close: vi.fn().mockResolvedValue(undefined),
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
})
|
});
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Helper function to clear mock storage between tests
|
// Helper function to clear mock storage between tests
|
||||||
export const clearMockStorage = () => {
|
export const clearMockStorage = () => {
|
||||||
mockStorage.clear()
|
mockStorage.clear();
|
||||||
}
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Mock implementation of Tone.js for integration tests
|
* Mock implementation of Tone.js for integration tests
|
||||||
* Provides interface compatibility while avoiding actual audio operations
|
* Provides interface compatibility while avoiding actual audio operations
|
||||||
*/
|
*/
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
// Mock Sampler class
|
// Mock Sampler class
|
||||||
export const mockSampler = {
|
export const mockSampler = {
|
||||||
@@ -16,7 +16,7 @@ export const mockSampler = {
|
|||||||
disconnect: vi.fn().mockReturnThis(),
|
disconnect: vi.fn().mockReturnThis(),
|
||||||
set: vi.fn().mockReturnThis(),
|
set: vi.fn().mockReturnThis(),
|
||||||
get: vi.fn().mockReturnValue({}),
|
get: vi.fn().mockReturnValue({}),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Mock Transport
|
// Mock Transport
|
||||||
export const mockTransport = {
|
export const mockTransport = {
|
||||||
@@ -30,7 +30,7 @@ export const mockTransport = {
|
|||||||
schedule: vi.fn(),
|
schedule: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Mock Tone namespace
|
// Mock Tone namespace
|
||||||
export const mockTone = {
|
export const mockTone = {
|
||||||
@@ -55,4 +55,4 @@ export const mockTone = {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
resume: vi.fn().mockResolvedValue(undefined),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
+35
-13
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock implementation of Tone.js for testing
|
* Mock implementation of Tone.js for testing
|
||||||
@@ -18,14 +18,14 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
|||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
toDestination: vi.fn()
|
toDestination: vi.fn()
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Mock Transport object
|
// Mock Transport object
|
||||||
export const MockTransport = {
|
export const MockTransport = {
|
||||||
start: vi.fn(),
|
start: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
position: '0:0:0',
|
position: 0,
|
||||||
bpm: {
|
bpm: {
|
||||||
value: 120,
|
value: 120,
|
||||||
rampTo: vi.fn()
|
rampTo: vi.fn()
|
||||||
@@ -34,9 +34,22 @@ export const MockTransport = {
|
|||||||
state: 'stopped',
|
state: 'stopped',
|
||||||
scheduleOnce: vi.fn(),
|
scheduleOnce: vi.fn(),
|
||||||
scheduleRepeat: vi.fn(),
|
scheduleRepeat: vi.fn(),
|
||||||
|
schedule: vi.fn().mockReturnValue(1),
|
||||||
cancel: vi.fn(),
|
cancel: vi.fn(),
|
||||||
clear: vi.fn()
|
clear: vi.fn()
|
||||||
}
|
,
|
||||||
|
setLoopPoints: vi.fn(),
|
||||||
|
loop: false,
|
||||||
|
PPQ: 192,
|
||||||
|
getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({
|
||||||
|
callback,
|
||||||
|
interval,
|
||||||
|
start: vi.fn(),
|
||||||
|
dispose: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock Destination
|
// Mock Destination
|
||||||
export const MockDestination = {
|
export const MockDestination = {
|
||||||
@@ -46,7 +59,7 @@ export const MockDestination = {
|
|||||||
mute: false,
|
mute: false,
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn()
|
disconnect: vi.fn()
|
||||||
}
|
};
|
||||||
|
|
||||||
// Mock ToneAudioBuffer
|
// Mock ToneAudioBuffer
|
||||||
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
||||||
@@ -55,7 +68,7 @@ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
load: vi.fn().mockResolvedValue(undefined)
|
load: vi.fn().mockResolvedValue(undefined)
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Mock Gain node
|
// Mock Gain node
|
||||||
export const MockGain = vi.fn().mockImplementation(() => ({
|
export const MockGain = vi.fn().mockImplementation(() => ({
|
||||||
@@ -68,7 +81,7 @@ export const MockGain = vi.fn().mockImplementation(() => ({
|
|||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
dispose: vi.fn()
|
dispose: vi.fn()
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Mock Meter
|
// Mock Meter
|
||||||
export const MockMeter = vi.fn().mockImplementation(() => ({
|
export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||||
@@ -76,11 +89,12 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
|
|||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
dispose: vi.fn()
|
dispose: vi.fn()
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Complete Tone.js mock
|
// Complete Tone.js mock
|
||||||
export const ToneMock = {
|
export const ToneMock = {
|
||||||
Sampler: MockSampler,
|
Sampler: MockSampler,
|
||||||
|
Loop: MockLoop,
|
||||||
Transport: MockTransport,
|
Transport: MockTransport,
|
||||||
Destination: MockDestination,
|
Destination: MockDestination,
|
||||||
ToneAudioBuffer: MockToneAudioBuffer,
|
ToneAudioBuffer: MockToneAudioBuffer,
|
||||||
@@ -93,7 +107,14 @@ export const ToneMock = {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
resume: vi.fn().mockResolvedValue(undefined),
|
||||||
suspend: vi.fn().mockResolvedValue(undefined),
|
suspend: vi.fn().mockResolvedValue(undefined),
|
||||||
close: vi.fn().mockResolvedValue(undefined)
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
lookAhead: 0.05,
|
||||||
|
setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
|
||||||
|
return window.setTimeout(fn, timeoutSeconds * 1000);
|
||||||
|
}),
|
||||||
|
clearTimeout: vi.fn().mockImplementation((id: number) => {
|
||||||
|
window.clearTimeout(id);
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Time utilities
|
// Time utilities
|
||||||
@@ -106,10 +127,11 @@ export const ToneMock = {
|
|||||||
Frequency: vi.fn().mockImplementation((freq) => ({
|
Frequency: vi.fn().mockImplementation((freq) => ({
|
||||||
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
|
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
|
||||||
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
|
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
|
||||||
}))
|
})),
|
||||||
}
|
now: vi.fn().mockImplementation(() => Date.now() / 1000)
|
||||||
|
};
|
||||||
|
|
||||||
// Setup the global mock
|
// Setup the global mock
|
||||||
export const setupToneMocks = () => {
|
export const setupToneMocks = () => {
|
||||||
vi.doMock('tone', () => ToneMock)
|
vi.doMock('tone', () => ToneMock);
|
||||||
}
|
};
|
||||||
|
|||||||
+18
-18
@@ -1,13 +1,13 @@
|
|||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom';
|
||||||
import 'reflect-metadata' // Required for class-transformer decorators
|
import 'reflect-metadata'; // Required for class-transformer decorators
|
||||||
import { beforeAll, afterEach, vi } from 'vitest'
|
import { beforeAll, afterEach, vi } from 'vitest';
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
// Import our custom Tone.js mocks
|
// Import our custom Tone.js mocks
|
||||||
import { setupToneMocks } from './mocks/tone'
|
import { setupToneMocks } from './mocks/tone';
|
||||||
|
|
||||||
// Setup global mocks
|
// Setup global mocks
|
||||||
setupToneMocks()
|
setupToneMocks();
|
||||||
|
|
||||||
// Mock KGCore globally to prevent store initialization issues
|
// Mock KGCore globally to prevent store initialization issues
|
||||||
vi.mock('../core/KGCore', () => ({
|
vi.mock('../core/KGCore', () => ({
|
||||||
@@ -24,22 +24,22 @@ vi.mock('../core/KGCore', () => ({
|
|||||||
executeCommand: vi.fn()
|
executeCommand: vi.fn()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Global test setup for all unit tests
|
// Global test setup for all unit tests
|
||||||
|
|
||||||
// Clean up after each test
|
// Clean up after each test
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup();
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Setup before all tests
|
// Setup before all tests
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Mock console methods to reduce noise in tests
|
// Mock console methods to reduce noise in tests
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
// Mock window.matchMedia (needed for some UI components)
|
// Mock window.matchMedia (needed for some UI components)
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
@@ -54,16 +54,16 @@ beforeAll(() => {
|
|||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
})
|
});
|
||||||
|
|
||||||
// Mock ResizeObserver (might be needed for some components)
|
// Mock ResizeObserver (might be needed for some components)
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Mock URL.createObjectURL (might be needed for file operations)
|
// Mock URL.createObjectURL (might be needed for file operations)
|
||||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url')
|
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
|
||||||
global.URL.revokeObjectURL = vi.fn()
|
global.URL.revokeObjectURL = vi.fn();
|
||||||
})
|
});
|
||||||
+27
-27
@@ -1,7 +1,7 @@
|
|||||||
import { KGMidiNote } from '../../core/midi/KGMidiNote'
|
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||||
import { KGProject } from '../../core/KGProject'
|
import { KGProject } from '../../core/KGProject';
|
||||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack'
|
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion'
|
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test data factories for creating mock objects
|
* Test data factories for creating mock objects
|
||||||
@@ -22,7 +22,7 @@ export const createMockMidiNote = (overrides: Partial<{
|
|||||||
pitch: 60, // Middle C
|
pitch: 60, // Middle C
|
||||||
velocity: 80,
|
velocity: 80,
|
||||||
...overrides
|
...overrides
|
||||||
}
|
};
|
||||||
|
|
||||||
return new KGMidiNote(
|
return new KGMidiNote(
|
||||||
defaults.id,
|
defaults.id,
|
||||||
@@ -30,8 +30,8 @@ export const createMockMidiNote = (overrides: Partial<{
|
|||||||
defaults.endBeat,
|
defaults.endBeat,
|
||||||
defaults.pitch,
|
defaults.pitch,
|
||||||
defaults.velocity
|
defaults.velocity
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createMockMidiRegion = (overrides: Partial<{
|
export const createMockMidiRegion = (overrides: Partial<{
|
||||||
id: string
|
id: string
|
||||||
@@ -50,7 +50,7 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
startFromBeat: 0,
|
startFromBeat: 0,
|
||||||
length: 4,
|
length: 4,
|
||||||
...overrides
|
...overrides
|
||||||
}
|
};
|
||||||
|
|
||||||
const region = new KGMidiRegion(
|
const region = new KGMidiRegion(
|
||||||
defaults.id,
|
defaults.id,
|
||||||
@@ -59,15 +59,15 @@ export const createMockMidiRegion = (overrides: Partial<{
|
|||||||
defaults.name,
|
defaults.name,
|
||||||
defaults.startFromBeat,
|
defaults.startFromBeat,
|
||||||
defaults.length
|
defaults.length
|
||||||
)
|
);
|
||||||
|
|
||||||
// Add notes if provided
|
// Add notes if provided
|
||||||
if (overrides.notes) {
|
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<{
|
export const createMockMidiTrack = (overrides: Partial<{
|
||||||
name: string
|
name: string
|
||||||
@@ -82,22 +82,22 @@ export const createMockMidiTrack = (overrides: Partial<{
|
|||||||
instrument: 'acoustic_grand_piano' as const,
|
instrument: 'acoustic_grand_piano' as const,
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
...overrides
|
...overrides
|
||||||
}
|
};
|
||||||
|
|
||||||
const track = new KGMidiTrack(
|
const track = new KGMidiTrack(
|
||||||
defaults.name,
|
defaults.name,
|
||||||
defaults.id,
|
defaults.id,
|
||||||
defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP,
|
defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP,
|
||||||
defaults.volume
|
defaults.volume
|
||||||
)
|
);
|
||||||
|
|
||||||
// Add regions if provided
|
// Add regions if provided
|
||||||
if (overrides.regions) {
|
if (overrides.regions) {
|
||||||
track.setRegions(overrides.regions)
|
track.setRegions(overrides.regions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createMockProject = (overrides: Partial<{
|
export const createMockProject = (overrides: Partial<{
|
||||||
name: string
|
name: string
|
||||||
@@ -111,7 +111,7 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
timeSignature: { numerator: 4, denominator: 4 },
|
timeSignature: { numerator: 4, denominator: 4 },
|
||||||
tracks: [],
|
tracks: [],
|
||||||
...overrides
|
...overrides
|
||||||
}
|
};
|
||||||
|
|
||||||
const project = new KGProject(
|
const project = new KGProject(
|
||||||
defaults.name,
|
defaults.name,
|
||||||
@@ -126,34 +126,34 @@ export const createMockProject = (overrides: Partial<{
|
|||||||
1, // barWidthMultiplier
|
1, // barWidthMultiplier
|
||||||
defaults.tracks, // tracks
|
defaults.tracks, // tracks
|
||||||
5 // projectStructureVersion
|
5 // projectStructureVersion
|
||||||
)
|
);
|
||||||
|
|
||||||
return project
|
return project;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Common test scenarios
|
// Common test scenarios
|
||||||
export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => {
|
export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => {
|
||||||
const notes = [
|
const notes = [
|
||||||
createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }),
|
createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }),
|
||||||
createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }),
|
createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }),
|
||||||
]
|
];
|
||||||
|
|
||||||
const region = createMockMidiRegion({
|
const region = createMockMidiRegion({
|
||||||
id: 'region-1',
|
id: 'region-1',
|
||||||
trackId: 'track-1',
|
trackId: 'track-1',
|
||||||
notes
|
notes
|
||||||
})
|
});
|
||||||
|
|
||||||
const track = createMockMidiTrack({
|
const track = createMockMidiTrack({
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Track 1',
|
name: 'Track 1',
|
||||||
regions: [region]
|
regions: [region]
|
||||||
})
|
});
|
||||||
|
|
||||||
const project = createMockProject({
|
const project = createMockProject({
|
||||||
name: 'Basic Test Project',
|
name: 'Basic Test Project',
|
||||||
tracks: [track]
|
tracks: [track]
|
||||||
})
|
});
|
||||||
|
|
||||||
return { project, track, region }
|
return { project, track, region };
|
||||||
}
|
};
|
||||||
@@ -2,43 +2,43 @@
|
|||||||
* Integration test setup utilities
|
* Integration test setup utilities
|
||||||
* Common setup and teardown for integration tests
|
* Common setup and teardown for integration tests
|
||||||
*/
|
*/
|
||||||
import { beforeEach, afterEach, vi } from 'vitest'
|
import { beforeEach, afterEach, vi } from 'vitest';
|
||||||
import { mockAudioInterface } from '../mocks/audio-interface'
|
import { mockAudioInterface } from '../mocks/audio-interface';
|
||||||
import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db'
|
import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db';
|
||||||
import { mockTone } from '../mocks/tone-js'
|
import { mockTone } from '../mocks/tone-js';
|
||||||
import { KGCore } from '../../core/KGCore'
|
import { KGCore } from '../../core/KGCore';
|
||||||
import { KGProject } from '../../core/KGProject'
|
import { KGProject } from '../../core/KGProject';
|
||||||
|
|
||||||
// Initialize KGCore with a default project at module load time
|
// Initialize KGCore with a default project at module load time
|
||||||
// This prevents errors when projectStore module initializes and tries to access currentProject
|
// This prevents errors when projectStore module initializes and tries to access currentProject
|
||||||
// This runs synchronously when the module is imported, before any tests
|
// This runs synchronously when the module is imported, before any tests
|
||||||
const initializeKGCore = async () => {
|
const initializeKGCore = async () => {
|
||||||
const defaultProject = new KGProject('Test Setup Project')
|
const defaultProject = new KGProject('Test Setup Project');
|
||||||
const core = KGCore.instance()
|
const core = KGCore.instance();
|
||||||
await core.initialize()
|
await core.initialize();
|
||||||
core.setCurrentProject(defaultProject)
|
core.setCurrentProject(defaultProject);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Run initialization immediately at module load
|
// Run initialization immediately at module load
|
||||||
await initializeKGCore()
|
await initializeKGCore();
|
||||||
|
|
||||||
// Global setup for integration tests
|
// Global setup for integration tests
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear all mocks
|
// Clear all mocks
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks();
|
||||||
|
|
||||||
// Clear mock storage
|
// Clear mock storage
|
||||||
clearMockStorage()
|
clearMockStorage();
|
||||||
|
|
||||||
// Mock external dependencies while keeping internal components real
|
// Mock external dependencies while keeping internal components real
|
||||||
vi.doMock('../../audio/KGAudioInterface', () => ({
|
vi.doMock('../../audio/KGAudioInterface', () => ({
|
||||||
KGAudioInterface: mockAudioInterface,
|
KGAudioInterface: mockAudioInterface,
|
||||||
default: 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
|
// Mock browser APIs that might be used
|
||||||
Object.defineProperty(window, 'AudioContext', {
|
Object.defineProperty(window, 'AudioContext', {
|
||||||
@@ -47,19 +47,19 @@ beforeEach(() => {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
resume: vi.fn().mockResolvedValue(undefined),
|
resume: vi.fn().mockResolvedValue(undefined),
|
||||||
})),
|
})),
|
||||||
})
|
});
|
||||||
|
|
||||||
Object.defineProperty(window, 'webkitAudioContext', {
|
Object.defineProperty(window, 'webkitAudioContext', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: window.AudioContext,
|
value: window.AudioContext,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Clean up after each test
|
// Clean up after each test
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks();
|
||||||
clearMockStorage()
|
clearMockStorage();
|
||||||
})
|
});
|
||||||
|
|
||||||
// Helper functions for integration tests
|
// Helper functions for integration tests
|
||||||
export const createMockProject = () => {
|
export const createMockProject = () => {
|
||||||
@@ -73,8 +73,8 @@ export const createMockProject = () => {
|
|||||||
keySignature: 'C major',
|
keySignature: 'C major',
|
||||||
maxBars: 32,
|
maxBars: 32,
|
||||||
tracks: [],
|
tracks: [],
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createMockTrack = (name = 'Test Track') => {
|
export const createMockTrack = (name = 'Test Track') => {
|
||||||
return {
|
return {
|
||||||
@@ -83,8 +83,8 @@ export const createMockTrack = (name = 'Test Track') => {
|
|||||||
instrument: 'acoustic_grand_piano',
|
instrument: 'acoustic_grand_piano',
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
regions: [],
|
regions: [],
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createMockRegion = (name = 'Test Region') => {
|
export const createMockRegion = (name = 'Test Region') => {
|
||||||
return {
|
return {
|
||||||
@@ -93,8 +93,8 @@ export const createMockRegion = (name = 'Test Region') => {
|
|||||||
startBeat: 0,
|
startBeat: 0,
|
||||||
endBeat: 4,
|
endBeat: 4,
|
||||||
notes: [],
|
notes: [],
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
|
export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
|
||||||
return {
|
return {
|
||||||
@@ -103,5 +103,5 @@ export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
|
|||||||
startBeat,
|
startBeat,
|
||||||
endBeat,
|
endBeat,
|
||||||
velocity: 100,
|
velocity: 100,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react';
|
||||||
import { render, type RenderOptions } from '@testing-library/react'
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
|
|
||||||
// Custom render function that includes any providers your app needs
|
// Custom render function that includes any providers your app needs
|
||||||
// This can be extended later with Zustand store providers, etc.
|
// 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),
|
// If you need to wrap components with providers (like Zustand store),
|
||||||
// you can create an AllTheProviders wrapper here
|
// you can create an AllTheProviders wrapper here
|
||||||
|
|
||||||
return render(ui, options)
|
return render(ui, options);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Re-export everything from testing-library/react
|
// Re-export everything from testing-library/react
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export * from '@testing-library/react'
|
export * from '@testing-library/react';
|
||||||
export { customRender as render }
|
export { customRender as render };
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface ConfirmOptions {
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptOptions {
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSigResult {
|
||||||
|
numerator: number;
|
||||||
|
denominator: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
|
||||||
|
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
|
||||||
|
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
||||||
|
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||||
|
|
||||||
|
export function registerDialogFns(
|
||||||
|
alertFn: (message: string) => Promise<void>,
|
||||||
|
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
|
||||||
|
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
|
||||||
|
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
||||||
|
) {
|
||||||
|
_showAlertFn = alertFn;
|
||||||
|
_showConfirmFn = confirmFn;
|
||||||
|
_showPromptFn = promptFn;
|
||||||
|
_showTimeSigFn = timeSigFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showAlert(message: string): Promise<void> {
|
||||||
|
if (!_showAlertFn) {
|
||||||
|
window.alert(message);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return _showAlertFn(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showConfirm(message: string, options?: ConfirmOptions): Promise<boolean> {
|
||||||
|
if (!_showConfirmFn) {
|
||||||
|
return Promise.resolve(window.confirm(message));
|
||||||
|
}
|
||||||
|
return _showConfirmFn(message, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPrompt(message: string, defaultValue?: string, options?: PromptOptions): Promise<string | null> {
|
||||||
|
if (!_showPromptFn) {
|
||||||
|
return Promise.resolve(window.prompt(message, defaultValue));
|
||||||
|
}
|
||||||
|
return _showPromptFn(message, defaultValue, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
|
||||||
|
if (!_showTimeSigFn) {
|
||||||
|
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
|
||||||
|
if (!raw) return Promise.resolve(null);
|
||||||
|
const [n, d] = raw.split('/').map(Number);
|
||||||
|
if (!n || !d) return Promise.resolve(null);
|
||||||
|
return Promise.resolve({ numerator: n, denominator: d });
|
||||||
|
}
|
||||||
|
return _showTimeSigFn(message, defaultValue);
|
||||||
|
}
|
||||||
+113
-113
@@ -1,186 +1,186 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
beatsToBar,
|
beatsToBar,
|
||||||
pitchToNoteNameString,
|
pitchToNoteNameString,
|
||||||
pitchToNoteName,
|
pitchToNoteName,
|
||||||
pianoRollIndexToPitch,
|
pianoRollIndexToPitch,
|
||||||
noteNameToPitch
|
noteNameToPitch
|
||||||
} from './midiUtil'
|
} from './midiUtil';
|
||||||
|
|
||||||
describe('midiUtil', () => {
|
describe('midiUtil', () => {
|
||||||
describe('beatsToBar', () => {
|
describe('beatsToBar', () => {
|
||||||
it('should convert beats to bar position object', () => {
|
it('should convert beats to bar position object', () => {
|
||||||
const result1 = beatsToBar(0, { numerator: 4, denominator: 4 })
|
const result1 = beatsToBar(0, { numerator: 4, denominator: 4 });
|
||||||
expect(result1.bar).toBe(0)
|
expect(result1.bar).toBe(0);
|
||||||
expect(result1.beatInBar).toBe(0)
|
expect(result1.beatInBar).toBe(0);
|
||||||
|
|
||||||
const result2 = beatsToBar(4, { numerator: 4, denominator: 4 })
|
const result2 = beatsToBar(4, { numerator: 4, denominator: 4 });
|
||||||
expect(result2.bar).toBe(1)
|
expect(result2.bar).toBe(1);
|
||||||
expect(result2.beatInBar).toBe(0)
|
expect(result2.beatInBar).toBe(0);
|
||||||
|
|
||||||
const result3 = beatsToBar(8, { numerator: 4, denominator: 4 })
|
const result3 = beatsToBar(8, { numerator: 4, denominator: 4 });
|
||||||
expect(result3.bar).toBe(2)
|
expect(result3.bar).toBe(2);
|
||||||
expect(result3.beatInBar).toBe(0)
|
expect(result3.beatInBar).toBe(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle different time signatures', () => {
|
it('should handle different time signatures', () => {
|
||||||
const result1 = beatsToBar(0, { numerator: 3, denominator: 4 })
|
const result1 = beatsToBar(0, { numerator: 3, denominator: 4 });
|
||||||
expect(result1.bar).toBe(0)
|
expect(result1.bar).toBe(0);
|
||||||
expect(result1.beatInBar).toBe(0)
|
expect(result1.beatInBar).toBe(0);
|
||||||
|
|
||||||
const result2 = beatsToBar(3, { numerator: 3, denominator: 4 })
|
const result2 = beatsToBar(3, { numerator: 3, denominator: 4 });
|
||||||
expect(result2.bar).toBe(1)
|
expect(result2.bar).toBe(1);
|
||||||
expect(result2.beatInBar).toBe(0)
|
expect(result2.beatInBar).toBe(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle fractional beats', () => {
|
it('should handle fractional beats', () => {
|
||||||
const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 })
|
const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 });
|
||||||
expect(result1.bar).toBe(0)
|
expect(result1.bar).toBe(0);
|
||||||
expect(result1.beatInBar).toBe(2.5)
|
expect(result1.beatInBar).toBe(2.5);
|
||||||
|
|
||||||
const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 })
|
const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 });
|
||||||
expect(result2.bar).toBe(1)
|
expect(result2.bar).toBe(1);
|
||||||
expect(result2.beatInBar).toBe(0.5)
|
expect(result2.beatInBar).toBe(0.5);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('pitchToNoteNameString', () => {
|
describe('pitchToNoteNameString', () => {
|
||||||
it('should convert MIDI pitch to note name with octave', () => {
|
it('should convert MIDI pitch to note name with octave', () => {
|
||||||
expect(pitchToNoteNameString(60)).toBe('C4') // Middle C
|
expect(pitchToNoteNameString(60)).toBe('C4'); // Middle C
|
||||||
expect(pitchToNoteNameString(61)).toBe('C#4') // C# above middle C
|
expect(pitchToNoteNameString(61)).toBe('C#4'); // C# above middle C
|
||||||
expect(pitchToNoteNameString(59)).toBe('B3') // B below middle C
|
expect(pitchToNoteNameString(59)).toBe('B3'); // B below middle C
|
||||||
expect(pitchToNoteNameString(72)).toBe('C5') // C one octave above 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(48)).toBe('C3'); // C one octave below middle C
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle edge cases', () => {
|
it('should handle edge cases', () => {
|
||||||
expect(pitchToNoteNameString(0)).toBe('C-1') // Lowest MIDI note
|
expect(pitchToNoteNameString(0)).toBe('C-1'); // Lowest MIDI note
|
||||||
expect(pitchToNoteNameString(127)).toBe('G9') // Highest MIDI note
|
expect(pitchToNoteNameString(127)).toBe('G9'); // Highest MIDI note
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle all chromatic notes', () => {
|
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++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i])
|
expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('pitchToNoteName', () => {
|
describe('pitchToNoteName', () => {
|
||||||
it('should convert pitch to note name object', () => {
|
it('should convert pitch to note name object', () => {
|
||||||
const result60 = pitchToNoteName(60) // Middle C
|
const result60 = pitchToNoteName(60); // Middle C
|
||||||
expect(result60.note).toBe('C')
|
expect(result60.note).toBe('C');
|
||||||
expect(result60.octave).toBe(4)
|
expect(result60.octave).toBe(4);
|
||||||
|
|
||||||
const result61 = pitchToNoteName(61) // C#
|
const result61 = pitchToNoteName(61); // C#
|
||||||
expect(result61.note).toBe('C#')
|
expect(result61.note).toBe('C#');
|
||||||
expect(result61.octave).toBe(4)
|
expect(result61.octave).toBe(4);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should wrap around for different octaves', () => {
|
it('should wrap around for different octaves', () => {
|
||||||
const result60 = pitchToNoteName(60)
|
const result60 = pitchToNoteName(60);
|
||||||
const result72 = pitchToNoteName(72)
|
const result72 = pitchToNoteName(72);
|
||||||
const result84 = pitchToNoteName(84)
|
const result84 = pitchToNoteName(84);
|
||||||
|
|
||||||
expect(result60.note).toBe('C')
|
expect(result60.note).toBe('C');
|
||||||
expect(result72.note).toBe('C')
|
expect(result72.note).toBe('C');
|
||||||
expect(result84.note).toBe('C')
|
expect(result84.note).toBe('C');
|
||||||
|
|
||||||
expect(result60.octave).toBe(4)
|
expect(result60.octave).toBe(4);
|
||||||
expect(result72.octave).toBe(5)
|
expect(result72.octave).toBe(5);
|
||||||
expect(result84.octave).toBe(6)
|
expect(result84.octave).toBe(6);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('pianoRollIndexToPitch', () => {
|
describe('pianoRollIndexToPitch', () => {
|
||||||
it('should convert piano roll row index to MIDI pitch', () => {
|
it('should convert piano roll row index to MIDI pitch', () => {
|
||||||
// This function likely maps visual rows to MIDI pitches
|
// This function likely maps visual rows to MIDI pitches
|
||||||
// The exact mapping depends on your implementation
|
// The exact mapping depends on your implementation
|
||||||
const result = pianoRollIndexToPitch(10)
|
const result = pianoRollIndexToPitch(10);
|
||||||
expect(typeof result).toBe('number')
|
expect(typeof result).toBe('number');
|
||||||
expect(result).toBeGreaterThanOrEqual(0)
|
expect(result).toBeGreaterThanOrEqual(0);
|
||||||
expect(result).toBeLessThanOrEqual(127)
|
expect(result).toBeLessThanOrEqual(127);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return different pitches for different indices', () => {
|
it('should return different pitches for different indices', () => {
|
||||||
const pitch1 = pianoRollIndexToPitch(0)
|
const pitch1 = pianoRollIndexToPitch(0);
|
||||||
const pitch2 = pianoRollIndexToPitch(1)
|
const pitch2 = pianoRollIndexToPitch(1);
|
||||||
expect(pitch1).not.toBe(pitch2)
|
expect(pitch1).not.toBe(pitch2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('noteNameToPitch', () => {
|
describe('noteNameToPitch', () => {
|
||||||
it('should convert note names to MIDI pitch', () => {
|
it('should convert note names to MIDI pitch', () => {
|
||||||
expect(noteNameToPitch('C4')).toBe(60) // Middle C
|
expect(noteNameToPitch('C4')).toBe(60); // Middle C
|
||||||
expect(noteNameToPitch('C#4')).toBe(61) // C# above middle C
|
expect(noteNameToPitch('C#4')).toBe(61); // C# above middle C
|
||||||
expect(noteNameToPitch('D4')).toBe(62) // D above middle C
|
expect(noteNameToPitch('D4')).toBe(62); // D above middle C
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle different octaves', () => {
|
it('should handle different octaves', () => {
|
||||||
expect(noteNameToPitch('C3')).toBe(48) // C below middle C
|
expect(noteNameToPitch('C3')).toBe(48); // C below middle C
|
||||||
expect(noteNameToPitch('C5')).toBe(72) // C above middle C
|
expect(noteNameToPitch('C5')).toBe(72); // C above middle C
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle sharps', () => {
|
it('should handle sharps', () => {
|
||||||
expect(noteNameToPitch('C#4')).toBe(61)
|
expect(noteNameToPitch('C#4')).toBe(61);
|
||||||
expect(noteNameToPitch('F#4')).toBe(66)
|
expect(noteNameToPitch('F#4')).toBe(66);
|
||||||
expect(noteNameToPitch('G#4')).toBe(68)
|
expect(noteNameToPitch('G#4')).toBe(68);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle invalid note names', () => {
|
it('should handle invalid note names', () => {
|
||||||
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4') // Flats not supported
|
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported
|
||||||
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4') // Invalid note
|
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
|
||||||
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C') // Missing octave
|
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle negative values gracefully', () => {
|
it('should handle negative values gracefully', () => {
|
||||||
expect(() => pitchToNoteNameString(-1)).not.toThrow()
|
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
||||||
expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow()
|
expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle very large values', () => {
|
it('should handle very large values', () => {
|
||||||
expect(() => pitchToNoteNameString(200)).not.toThrow()
|
expect(() => pitchToNoteNameString(200)).not.toThrow();
|
||||||
expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow()
|
expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle zero values', () => {
|
it('should handle zero values', () => {
|
||||||
expect(pitchToNoteNameString(0)).toBeDefined()
|
expect(pitchToNoteNameString(0)).toBeDefined();
|
||||||
expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined()
|
expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('mathematical consistency', () => {
|
describe('mathematical consistency', () => {
|
||||||
it('should maintain pitch relationships', () => {
|
it('should maintain pitch relationships', () => {
|
||||||
// One octave = 12 semitones - note names should be the same
|
// One octave = 12 semitones - note names should be the same
|
||||||
const baseNote = pitchToNoteName(60)
|
const baseNote = pitchToNoteName(60);
|
||||||
const octaveNote = pitchToNoteName(72)
|
const octaveNote = pitchToNoteName(72);
|
||||||
expect(baseNote.note).toBe(octaveNote.note) // Both should be 'C'
|
expect(baseNote.note).toBe(octaveNote.note); // Both should be 'C'
|
||||||
expect(octaveNote.octave).toBe(baseNote.octave + 1) // Octave should be one higher
|
expect(octaveNote.octave).toBe(baseNote.octave + 1); // Octave should be one higher
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should maintain beat-to-bar relationships', () => {
|
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
|
// Should increment bar by 1 for each complete measure
|
||||||
for (let beat = 0; beat < 20; beat += 4) {
|
for (let beat = 0; beat < 20; beat += 4) {
|
||||||
const expectedBar = Math.floor(beat / 4)
|
const expectedBar = Math.floor(beat / 4);
|
||||||
const result = beatsToBar(beat, timeSignature)
|
const result = beatsToBar(beat, timeSignature);
|
||||||
expect(result.bar).toBe(expectedBar)
|
expect(result.bar).toBe(expectedBar);
|
||||||
expect(result.beatInBar).toBe(0) // Should be at start of bar
|
expect(result.beatInBar).toBe(0); // Should be at start of bar
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should maintain note name to pitch conversion consistency', () => {
|
it('should maintain note name to pitch conversion consistency', () => {
|
||||||
// Converting pitch to note name and back should be consistent
|
// Converting pitch to note name and back should be consistent
|
||||||
const originalPitch = 60
|
const originalPitch = 60;
|
||||||
const noteObj = pitchToNoteName(originalPitch)
|
const noteObj = pitchToNoteName(originalPitch);
|
||||||
const noteName = `${noteObj.note}${noteObj.octave}`
|
const noteName = `${noteObj.note}${noteObj.octave}`;
|
||||||
const convertedPitch = noteNameToPitch(noteName)
|
const convertedPitch = noteNameToPitch(noteName);
|
||||||
|
|
||||||
expect(convertedPitch).toBe(originalPitch)
|
expect(convertedPitch).toBe(originalPitch);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||||
import { KGCore } from '../core/KGCore';
|
import { KGCore } from '../core/KGCore';
|
||||||
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
|
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
|
||||||
import { showAlert } from '../components/common/DialogProvider';
|
import { showAlert } from './dialogUtil';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save project utility function.
|
* Save project utility function.
|
||||||
|
|||||||
+292
-292
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
getRootNoteFromKeySignature,
|
getRootNoteFromKeySignature,
|
||||||
noteNameToPitchClass,
|
noteNameToPitchClass,
|
||||||
@@ -9,208 +9,208 @@ import {
|
|||||||
getMatchingChordsForPitch,
|
getMatchingChordsForPitch,
|
||||||
generatePianoGridBackground,
|
generatePianoGridBackground,
|
||||||
validateFunctionalChordsJSON
|
validateFunctionalChordsJSON
|
||||||
} from './scaleUtil'
|
} from './scaleUtil';
|
||||||
import { KGCore } from '../core/KGCore'
|
import { KGCore } from '../core/KGCore';
|
||||||
import type { KeySignature } from '../core/KGProject'
|
import type { KeySignature } from '../core/KGProject';
|
||||||
import functionalChordsData from '../../public/resources/modes/functional_chords.json'
|
import functionalChordsData from '../../public/resources/modes/functional_chords.json';
|
||||||
|
|
||||||
describe('scaleUtil', () => {
|
describe('scaleUtil', () => {
|
||||||
// Setup: Mock KGCore with real chord data
|
// Setup: Mock KGCore with real chord data
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Use real functional chords data from JSON file (includes name, steps, and chord data)
|
// 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', () => {
|
describe('getRootNoteFromKeySignature', () => {
|
||||||
it('should extract root note from C major', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
it('should default to C for invalid format', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C')
|
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C');
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'));
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('noteNameToPitchClass', () => {
|
describe('noteNameToPitchClass', () => {
|
||||||
it('should convert C to 0', () => {
|
it('should convert C to 0', () => {
|
||||||
expect(noteNameToPitchClass('C')).toBe(0)
|
expect(noteNameToPitchClass('C')).toBe(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert C# to 1', () => {
|
it('should convert C# to 1', () => {
|
||||||
expect(noteNameToPitchClass('C#')).toBe(1)
|
expect(noteNameToPitchClass('C#')).toBe(1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert Db to 1', () => {
|
it('should convert Db to 1', () => {
|
||||||
expect(noteNameToPitchClass('Db')).toBe(1)
|
expect(noteNameToPitchClass('Db')).toBe(1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert D to 2', () => {
|
it('should convert D to 2', () => {
|
||||||
expect(noteNameToPitchClass('D')).toBe(2)
|
expect(noteNameToPitchClass('D')).toBe(2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert E to 4', () => {
|
it('should convert E to 4', () => {
|
||||||
expect(noteNameToPitchClass('E')).toBe(4)
|
expect(noteNameToPitchClass('E')).toBe(4);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert F to 5', () => {
|
it('should convert F to 5', () => {
|
||||||
expect(noteNameToPitchClass('F')).toBe(5)
|
expect(noteNameToPitchClass('F')).toBe(5);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert F# to 6', () => {
|
it('should convert F# to 6', () => {
|
||||||
expect(noteNameToPitchClass('F#')).toBe(6)
|
expect(noteNameToPitchClass('F#')).toBe(6);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert G to 7', () => {
|
it('should convert G to 7', () => {
|
||||||
expect(noteNameToPitchClass('G')).toBe(7)
|
expect(noteNameToPitchClass('G')).toBe(7);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert A to 9', () => {
|
it('should convert A to 9', () => {
|
||||||
expect(noteNameToPitchClass('A')).toBe(9)
|
expect(noteNameToPitchClass('A')).toBe(9);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert Bb to 10', () => {
|
it('should convert Bb to 10', () => {
|
||||||
expect(noteNameToPitchClass('Bb')).toBe(10)
|
expect(noteNameToPitchClass('Bb')).toBe(10);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should convert B to 11', () => {
|
it('should convert B to 11', () => {
|
||||||
expect(noteNameToPitchClass('B')).toBe(11)
|
expect(noteNameToPitchClass('B')).toBe(11);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should default to 0 for invalid note', () => {
|
it('should default to 0 for invalid note', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
expect(noteNameToPitchClass('X')).toBe(0)
|
expect(noteNameToPitchClass('X')).toBe(0);
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'));
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getModeSteps', () => {
|
describe('getModeSteps', () => {
|
||||||
it('should return ionian steps', () => {
|
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', () => {
|
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', () => {
|
it('should default to ionian for invalid mode', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1])
|
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]);
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'));
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getScalePitchClasses', () => {
|
describe('getScalePitchClasses', () => {
|
||||||
it('should return C major scale pitch classes', () => {
|
it('should return C major scale pitch classes', () => {
|
||||||
const steps = [2, 2, 1, 2, 2, 2, 1]
|
const steps = [2, 2, 1, 2, 2, 2, 1];
|
||||||
const result = getScalePitchClasses('C', steps)
|
const result = getScalePitchClasses('C', steps);
|
||||||
expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B
|
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', () => {
|
it('should return D major scale pitch classes', () => {
|
||||||
const steps = [2, 2, 1, 2, 2, 2, 1]
|
const steps = [2, 2, 1, 2, 2, 2, 1];
|
||||||
const result = getScalePitchClasses('D', steps)
|
const result = getScalePitchClasses('D', steps);
|
||||||
expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C#
|
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', () => {
|
it('should return F# dorian scale pitch classes', () => {
|
||||||
const steps = [2, 1, 2, 2, 2, 1, 2]
|
const steps = [2, 1, 2, 2, 2, 1, 2];
|
||||||
const result = getScalePitchClasses('F#', steps)
|
const result = getScalePitchClasses('F#', steps);
|
||||||
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E
|
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getSuitableChords', () => {
|
describe('getSuitableChords', () => {
|
||||||
it('should return tonic chords for C major ionian', () => {
|
it('should return tonic chords for C major ionian', () => {
|
||||||
const result = getSuitableChords('C major', 'ionian', 'T')
|
const result = getSuitableChords('C major', 'ionian', 'T');
|
||||||
expect(result).toHaveProperty('I')
|
expect(result).toHaveProperty('I');
|
||||||
expect(result).toHaveProperty('vi')
|
expect(result).toHaveProperty('vi');
|
||||||
expect(result).toHaveProperty('iii')
|
expect(result).toHaveProperty('iii');
|
||||||
expect(result['I']).toEqual(['C', 'E', 'G'])
|
expect(result['I']).toEqual(['C', 'E', 'G']);
|
||||||
expect(result['vi']).toEqual(['A', 'C', 'E'])
|
expect(result['vi']).toEqual(['A', 'C', 'E']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return subdominant chords for C major ionian', () => {
|
it('should return subdominant chords for C major ionian', () => {
|
||||||
const result = getSuitableChords('C major', 'ionian', 'S')
|
const result = getSuitableChords('C major', 'ionian', 'S');
|
||||||
expect(result).toHaveProperty('IV')
|
expect(result).toHaveProperty('IV');
|
||||||
expect(result).toHaveProperty('ii')
|
expect(result).toHaveProperty('ii');
|
||||||
expect(result['IV']).toEqual(['F', 'A', 'C'])
|
expect(result['IV']).toEqual(['F', 'A', 'C']);
|
||||||
expect(result['ii']).toEqual(['D', 'F', 'A'])
|
expect(result['ii']).toEqual(['D', 'F', 'A']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return dominant chords for C major ionian', () => {
|
it('should return dominant chords for C major ionian', () => {
|
||||||
const result = getSuitableChords('C major', 'ionian', 'D')
|
const result = getSuitableChords('C major', 'ionian', 'D');
|
||||||
expect(result).toHaveProperty('V')
|
expect(result).toHaveProperty('V');
|
||||||
expect(result).toHaveProperty('V7')
|
expect(result).toHaveProperty('V7');
|
||||||
expect(result['V']).toEqual(['G', 'B', 'D'])
|
expect(result['V']).toEqual(['G', 'B', 'D']);
|
||||||
expect(result['V7']).toEqual(['G', 'B', 'D', 'F'])
|
expect(result['V7']).toEqual(['G', 'B', 'D', 'F']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should transpose chords for D major ionian', () => {
|
it('should transpose chords for D major ionian', () => {
|
||||||
const result = getSuitableChords('D major', 'ionian', 'T')
|
const result = getSuitableChords('D major', 'ionian', 'T');
|
||||||
expect(result['I']).toEqual(['D', 'F#', 'A'])
|
expect(result['I']).toEqual(['D', 'F#', 'A']);
|
||||||
expect(result['vi']).toEqual(['B', 'D', 'F#'])
|
expect(result['vi']).toEqual(['B', 'D', 'F#']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should transpose chords for F# major ionian', () => {
|
it('should transpose chords for F# major ionian', () => {
|
||||||
const result = getSuitableChords('F# major', 'ionian', 'T')
|
const result = getSuitableChords('F# major', 'ionian', 'T');
|
||||||
expect(result['I']).toEqual(['F#', 'A#', 'C#'])
|
expect(result['I']).toEqual(['F#', 'A#', 'C#']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return empty object for invalid mode', () => {
|
it('should return empty object for invalid mode', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const result = getSuitableChords('C major', 'invalid', 'T')
|
const result = getSuitableChords('C major', 'invalid', 'T');
|
||||||
expect(result).toEqual({})
|
expect(result).toEqual({});
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'));
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getChordNotesInKey', () => {
|
describe('getChordNotesInKey', () => {
|
||||||
it('should return I chord notes in C major ionian', () => {
|
it('should return I chord notes in C major ionian', () => {
|
||||||
const result = getChordNotesInKey('I', 'C major', 'ionian')
|
const result = getChordNotesInKey('I', 'C major', 'ionian');
|
||||||
expect(result).toEqual(['C', 'E', 'G'])
|
expect(result).toEqual(['C', 'E', 'G']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return V7 chord notes in C major ionian', () => {
|
it('should return V7 chord notes in C major ionian', () => {
|
||||||
const result = getChordNotesInKey('V7', 'C major', 'ionian')
|
const result = getChordNotesInKey('V7', 'C major', 'ionian');
|
||||||
expect(result).toEqual(['G', 'B', 'D', 'F'])
|
expect(result).toEqual(['G', 'B', 'D', 'F']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should transpose to D major', () => {
|
it('should transpose to D major', () => {
|
||||||
const result = getChordNotesInKey('I', 'D major', 'ionian')
|
const result = getChordNotesInKey('I', 'D major', 'ionian');
|
||||||
expect(result).toEqual(['D', 'F#', 'A'])
|
expect(result).toEqual(['D', 'F#', 'A']);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return empty array for invalid chord symbol', () => {
|
it('should return empty array for invalid chord symbol', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const result = getChordNotesInKey('invalid', 'C major', 'ionian')
|
const result = getChordNotesInKey('invalid', 'C major', 'ionian');
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([]);
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'));
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getMatchingChordsForPitch', () => {
|
describe('getMatchingChordsForPitch', () => {
|
||||||
describe('valid inputs', () => {
|
describe('valid inputs', () => {
|
||||||
it('should return matching chords for C (pitch 60) in C major ionian tonic', () => {
|
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"]
|
// 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)
|
// Pitch 60 = C (pitch class 0)
|
||||||
@@ -222,11 +222,11 @@ describe('scaleUtil', () => {
|
|||||||
[0, 4, 7], // I chord (C-E-G): C is root
|
[0, 4, 7], // I chord (C-E-G): C is root
|
||||||
[-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied
|
[-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
|
[-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', () => {
|
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)
|
// Pitch 64 = E (pitch class 4)
|
||||||
// Expected matches prioritized by position:
|
// Expected matches prioritized by position:
|
||||||
@@ -239,19 +239,19 @@ describe('scaleUtil', () => {
|
|||||||
[4, 7, 12], // I⁶ chord (E-G-C): E is root
|
[4, 7, 12], // I⁶ chord (E-G-C): E is root
|
||||||
[0, 4, 7], // I chord (C-E-G): E is 2nd
|
[0, 4, 7], // I chord (C-E-G): E is 2nd
|
||||||
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
|
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
|
||||||
])
|
]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should prioritize root matches over other positions', () => {
|
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)
|
// First chord should have C as root (position 0)
|
||||||
if (result.length > 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', () => {
|
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)
|
// 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"]
|
// 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
|
[5, 9, 12], // IV chord (F-A-C): F is root, offset applied
|
||||||
[2, 5, 9], // ii chord (D-F-A): F is 2nd
|
[2, 5, 9], // ii chord (D-F-A): F is 2nd
|
||||||
[-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied
|
[-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', () => {
|
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)
|
// 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"]
|
// 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([
|
expect(result).toEqual([
|
||||||
[7, 11, 14], // V chord (G-B-D): G is root, offset applied
|
[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
|
[7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied
|
||||||
])
|
]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should work with different modes (dorian)', () => {
|
it('should work with different modes (dorian)', () => {
|
||||||
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T')
|
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T');
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should transpose correctly for D major', () => {
|
it('should transpose correctly for D major', () => {
|
||||||
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D
|
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0);
|
||||||
expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2)
|
expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2)
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle all MIDI pitch ranges (low)', () => {
|
it('should handle all MIDI pitch ranges (low)', () => {
|
||||||
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1
|
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1
|
||||||
expect(Array.isArray(result)).toBe(true)
|
expect(Array.isArray(result)).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle all MIDI pitch ranges (high)', () => {
|
it('should handle all MIDI pitch ranges (high)', () => {
|
||||||
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8
|
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8
|
||||||
expect(Array.isArray(result)).toBe(true)
|
expect(Array.isArray(result)).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('edge cases', () => {
|
||||||
it('should return empty array for invalid mode', () => {
|
it('should return empty array for invalid mode', () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T')
|
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T');
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([]);
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle pitch class calculations correctly', () => {
|
it('should handle pitch class calculations correctly', () => {
|
||||||
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
|
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
|
||||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
|
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
|
||||||
expect(result1.length).toBe(result2.length) // Same chords match
|
expect(result1.length).toBe(result2.length); // Same chords match
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return pitch classes in ascending order', () => {
|
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 => {
|
result.forEach(chord => {
|
||||||
for (let i = 1; i < chord.length; i++) {
|
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', () => {
|
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
|
// All pitch classes should be < 12 after offset
|
||||||
result.forEach(chord => {
|
result.forEach(chord => {
|
||||||
chord.forEach(pitchClass => {
|
chord.forEach(pitchClass => {
|
||||||
expect(pitchClass).toBeLessThan(24) // Allowing for extended range
|
expect(pitchClass).toBeLessThan(24); // Allowing for extended range
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('pitch class calculations', () => {
|
describe('pitch class calculations', () => {
|
||||||
it('should correctly convert hover pitch to pitch class', () => {
|
it('should correctly convert hover pitch to pitch class', () => {
|
||||||
// C4 (60) should match same chords as C5 (72)
|
// C4 (60) should match same chords as C5 (72)
|
||||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
|
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
|
||||||
expect(result1).toEqual(result2)
|
expect(result1).toEqual(result2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should maintain ascending pitch order in results', () => {
|
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 => {
|
result.forEach(chord => {
|
||||||
for (let i = 1; i < chord.length; i++) {
|
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', () => {
|
describe('integration with helper functions', () => {
|
||||||
it('should work with getSuitableChords', () => {
|
it('should work with getSuitableChords', () => {
|
||||||
const suitableChords = getSuitableChords('C major', 'ionian', 'T')
|
const suitableChords = getSuitableChords('C major', 'ionian', 'T');
|
||||||
expect(Object.keys(suitableChords).length).toBeGreaterThan(0)
|
expect(Object.keys(suitableChords).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||||
expect(matchingChords.length).toBeGreaterThan(0)
|
expect(matchingChords.length).toBeGreaterThan(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should work with noteNameToPitchClass', () => {
|
it('should work with noteNameToPitchClass', () => {
|
||||||
const cPitch = noteNameToPitchClass('C')
|
const cPitch = noteNameToPitchClass('C');
|
||||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||||
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true)
|
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
|
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
|
||||||
// Verify the data has the expected structure
|
// Verify the data has the expected structure
|
||||||
const data = KGCore.FUNCTIONAL_CHORDS_DATA
|
const data = KGCore.FUNCTIONAL_CHORDS_DATA;
|
||||||
expect(data).toHaveProperty('ionian')
|
expect(data).toHaveProperty('ionian');
|
||||||
expect(data['ionian']).toHaveProperty('T')
|
expect(data['ionian']).toHaveProperty('T');
|
||||||
expect(data['ionian']).toHaveProperty('chords')
|
expect(data['ionian']).toHaveProperty('chords');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('generatePianoGridBackground', () => {
|
describe('generatePianoGridBackground', () => {
|
||||||
it('should generate CSS background for C major ionian', () => {
|
it('should generate CSS background for C major ionian', () => {
|
||||||
const result = generatePianoGridBackground('ionian', 'C major')
|
const result = generatePianoGridBackground('ionian', 'C major');
|
||||||
expect(result).toContain('linear-gradient')
|
expect(result).toContain('linear-gradient');
|
||||||
expect(typeof result).toBe('string')
|
expect(typeof result).toBe('string');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should generate different backgrounds for different modes', () => {
|
it('should generate different backgrounds for different modes', () => {
|
||||||
const ionian = generatePianoGridBackground('ionian', 'C major')
|
const ionian = generatePianoGridBackground('ionian', 'C major');
|
||||||
const dorian = generatePianoGridBackground('dorian', 'C major')
|
const dorian = generatePianoGridBackground('dorian', 'C major');
|
||||||
expect(ionian).not.toBe(dorian)
|
expect(ionian).not.toBe(dorian);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('validateFunctionalChordsJSON', () => {
|
describe('validateFunctionalChordsJSON', () => {
|
||||||
const validJSON = `{
|
const validJSON = `{
|
||||||
@@ -434,14 +434,14 @@ describe('scaleUtil', () => {
|
|||||||
"♭VII": ["Bb", "D", "F"]
|
"♭VII": ["Bb", "D", "F"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
|
|
||||||
describe('valid JSON', () => {
|
describe('valid JSON', () => {
|
||||||
it('should validate correct JSON with ionian and aeolian', () => {
|
it('should validate correct JSON with ionian and aeolian', () => {
|
||||||
const result = validateFunctionalChordsJSON(validJSON)
|
const result = validateFunctionalChordsJSON(validJSON);
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true);
|
||||||
expect(result.errors).toEqual([])
|
expect(result.errors).toEqual([]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should accept empty T/S/D arrays', () => {
|
it('should accept empty T/S/D arrays', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -453,10 +453,10 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should accept mode names with underscores, dashes, and spaces', () => {
|
it('should accept mode names with underscores, dashes, and spaces', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -468,10 +468,10 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should accept notes with sharp and flat', () => {
|
it('should accept notes with sharp and flat', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -485,10 +485,10 @@ describe('scaleUtil', () => {
|
|||||||
"I": ["C#", "Eb", "F#"]
|
"I": ["C#", "Eb", "F#"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should accept chord symbols with special characters', () => {
|
it('should accept chord symbols with special characters', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -505,24 +505,24 @@ describe('scaleUtil', () => {
|
|||||||
"♭II": ["Db", "F", "Ab"]
|
"♭II": ["Db", "F", "Ab"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(true)
|
expect(result.valid).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('invalid JSON', () => {
|
describe('invalid JSON', () => {
|
||||||
it('should reject malformed JSON', () => {
|
it('should reject malformed JSON', () => {
|
||||||
const result = validateFunctionalChordsJSON('{ invalid json }')
|
const result = validateFunctionalChordsJSON('{ invalid json }');
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain('Invalid JSON format')
|
expect(result.errors).toContain('Invalid JSON format');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject JSON array as root', () => {
|
it('should reject JSON array as root', () => {
|
||||||
const result = validateFunctionalChordsJSON('[]')
|
const result = validateFunctionalChordsJSON('[]');
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain('Root must be an object')
|
expect(result.errors).toContain('Root must be an object');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject missing ionian mode', () => {
|
it('should reject missing ionian mode', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -534,11 +534,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors).toContain('Missing required mode: "ionian"')
|
expect(result.errors).toContain('Missing required mode: "ionian"');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject invalid mode name', () => {
|
it('should reject invalid mode name', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -550,11 +550,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true)
|
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject steps with wrong number of elements', () => {
|
it('should reject steps with wrong number of elements', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -566,11 +566,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true)
|
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject steps that do not sum to 12', () => {
|
it('should reject steps that do not sum to 12', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -582,11 +582,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true)
|
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject non-integer steps', () => {
|
it('should reject non-integer steps', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -598,11 +598,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true)
|
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject invalid chord symbol in T/S/D', () => {
|
it('should reject invalid chord symbol in T/S/D', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -616,11 +616,11 @@ describe('scaleUtil', () => {
|
|||||||
"invalid123": ["C", "E", "G"]
|
"invalid123": ["C", "E", "G"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true)
|
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject chord referenced but not defined', () => {
|
it('should reject chord referenced but not defined', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -632,11 +632,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true)
|
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject invalid note name', () => {
|
it('should reject invalid note name', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -650,11 +650,11 @@ describe('scaleUtil', () => {
|
|||||||
"I": ["C", "X", "G"]
|
"I": ["C", "X", "G"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject note with invalid accidental', () => {
|
it('should reject note with invalid accidental', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -668,11 +668,11 @@ describe('scaleUtil', () => {
|
|||||||
"I": ["C##", "E", "G"]
|
"I": ["C##", "E", "G"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should reject lowercase note names', () => {
|
it('should reject lowercase note names', () => {
|
||||||
const json = `{
|
const json = `{
|
||||||
@@ -686,12 +686,12 @@ describe('scaleUtil', () => {
|
|||||||
"I": ["c", "e", "g"]
|
"I": ["c", "e", "g"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('error messages', () => {
|
describe('error messages', () => {
|
||||||
it('should provide descriptive error messages for multiple errors', () => {
|
it('should provide descriptive error messages for multiple errors', () => {
|
||||||
@@ -704,11 +704,11 @@ describe('scaleUtil', () => {
|
|||||||
"D": [],
|
"D": [],
|
||||||
"chords": {}
|
"chords": {}
|
||||||
}
|
}
|
||||||
}`
|
}`;
|
||||||
const result = validateFunctionalChordsJSON(json)
|
const result = validateFunctionalChordsJSON(json);
|
||||||
expect(result.valid).toBe(false)
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.length).toBeGreaterThan(1)
|
expect(result.errors.length).toBeGreaterThan(1);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
+162
-162
@@ -1,316 +1,316 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil'
|
import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil';
|
||||||
import { TIME_CONSTANTS } from '../constants/coreConstants'
|
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||||
|
|
||||||
describe('timeUtil', () => {
|
describe('timeUtil', () => {
|
||||||
describe('parseTimeSignature', () => {
|
describe('parseTimeSignature', () => {
|
||||||
it('should parse valid time signatures', () => {
|
it('should parse valid time signatures', () => {
|
||||||
expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 })
|
expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 });
|
||||||
expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 })
|
expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 });
|
||||||
expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 })
|
expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 });
|
||||||
expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 })
|
expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 });
|
||||||
expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 })
|
expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle whitespace around input', () => {
|
it('should handle whitespace around input', () => {
|
||||||
expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 })
|
expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 });
|
||||||
expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 })
|
expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 });
|
||||||
expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 })
|
expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return null for invalid formats', () => {
|
it('should return null for invalid formats', () => {
|
||||||
expect(parseTimeSignature('4')).toBeNull()
|
expect(parseTimeSignature('4')).toBeNull();
|
||||||
expect(parseTimeSignature('4/4/4')).toBeNull()
|
expect(parseTimeSignature('4/4/4')).toBeNull();
|
||||||
expect(parseTimeSignature('4-4')).toBeNull()
|
expect(parseTimeSignature('4-4')).toBeNull();
|
||||||
expect(parseTimeSignature('4:4')).toBeNull()
|
expect(parseTimeSignature('4:4')).toBeNull();
|
||||||
expect(parseTimeSignature('')).toBeNull()
|
expect(parseTimeSignature('')).toBeNull();
|
||||||
expect(parseTimeSignature('/')).toBeNull()
|
expect(parseTimeSignature('/')).toBeNull();
|
||||||
expect(parseTimeSignature('4/')).toBeNull()
|
expect(parseTimeSignature('4/')).toBeNull();
|
||||||
expect(parseTimeSignature('/4')).toBeNull()
|
expect(parseTimeSignature('/4')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return null for non-numeric values', () => {
|
it('should return null for non-numeric values', () => {
|
||||||
expect(parseTimeSignature('a/4')).toBeNull()
|
expect(parseTimeSignature('a/4')).toBeNull();
|
||||||
expect(parseTimeSignature('4/b')).toBeNull()
|
expect(parseTimeSignature('4/b')).toBeNull();
|
||||||
expect(parseTimeSignature('x/y')).toBeNull()
|
expect(parseTimeSignature('x/y')).toBeNull();
|
||||||
// Note: parseInt('4.5') returns 4, so these will parse as integers
|
// Note: parseInt('4.5') returns 4, so these will parse as integers
|
||||||
// Testing the actual behavior of parseInt
|
// Testing the actual behavior of parseInt
|
||||||
expect(parseTimeSignature('4.5/4')).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 })
|
expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return null for numerators not in available list', () => {
|
it('should return null for numerators not in available list', () => {
|
||||||
// Assuming TIME_CONSTANTS has specific available numerators
|
// Assuming TIME_CONSTANTS has specific available numerators
|
||||||
expect(parseTimeSignature('99/4')).toBeNull()
|
expect(parseTimeSignature('99/4')).toBeNull();
|
||||||
expect(parseTimeSignature('0/4')).toBeNull()
|
expect(parseTimeSignature('0/4')).toBeNull();
|
||||||
expect(parseTimeSignature('-1/4')).toBeNull()
|
expect(parseTimeSignature('-1/4')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return null for denominators not in available list', () => {
|
it('should return null for denominators not in available list', () => {
|
||||||
// Assuming TIME_CONSTANTS has specific available denominators
|
// Assuming TIME_CONSTANTS has specific available denominators
|
||||||
expect(parseTimeSignature('4/99')).toBeNull()
|
expect(parseTimeSignature('4/99')).toBeNull();
|
||||||
expect(parseTimeSignature('4/0')).toBeNull()
|
expect(parseTimeSignature('4/0')).toBeNull();
|
||||||
expect(parseTimeSignature('4/-1')).toBeNull()
|
expect(parseTimeSignature('4/-1')).toBeNull();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should validate against TIME_CONSTANTS available values', () => {
|
it('should validate against TIME_CONSTANTS available values', () => {
|
||||||
// Test that function actually uses TIME_CONSTANTS for validation
|
// Test that function actually uses TIME_CONSTANTS for validation
|
||||||
const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0]
|
const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0];
|
||||||
const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0]
|
const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0];
|
||||||
const invalidNumerator = 999 // Assuming this is not in the available list
|
const invalidNumerator = 999; // Assuming this is not in the available list
|
||||||
const invalidDenominator = 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(`${validNumerator}/${validDenominator}`)).not.toBeNull();
|
||||||
expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull()
|
expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull();
|
||||||
expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull()
|
expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('getTimeSignatureErrorMessage', () => {
|
describe('getTimeSignatureErrorMessage', () => {
|
||||||
it('should return a formatted error message with available options', () => {
|
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('Invalid time signature format');
|
||||||
expect(message).toContain('numerator/denominator')
|
expect(message).toContain('numerator/denominator');
|
||||||
expect(message).toContain('Available numerators:')
|
expect(message).toContain('Available numerators:');
|
||||||
expect(message).toContain('Available denominators:')
|
expect(message).toContain('Available denominators:');
|
||||||
expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8')
|
expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should include actual available values from TIME_CONSTANTS', () => {
|
it('should include actual available values from TIME_CONSTANTS', () => {
|
||||||
const message = getTimeSignatureErrorMessage()
|
const message = getTimeSignatureErrorMessage();
|
||||||
|
|
||||||
// Check that it includes values from TIME_CONSTANTS
|
// Check that it includes values from TIME_CONSTANTS
|
||||||
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => {
|
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 => {
|
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', () => {
|
it('should be a consistent message format', () => {
|
||||||
const message1 = getTimeSignatureErrorMessage()
|
const message1 = getTimeSignatureErrorMessage();
|
||||||
const message2 = getTimeSignatureErrorMessage()
|
const message2 = getTimeSignatureErrorMessage();
|
||||||
|
|
||||||
expect(message1).toBe(message2)
|
expect(message1).toBe(message2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('beatsToTimeString', () => {
|
describe('beatsToTimeString', () => {
|
||||||
it('should format beats to BBB:B | mm:ss:mmm format', () => {
|
it('should format beats to BBB:B | mm:ss:mmm format', () => {
|
||||||
// Test basic 4/4 time signature
|
// Test basic 4/4 time signature
|
||||||
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
|
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 }))
|
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 }))
|
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', () => {
|
it('should handle different time signatures correctly', () => {
|
||||||
// 3/4 time signature - 3 beats per bar
|
// 3/4 time signature - 3 beats per bar
|
||||||
expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 }))
|
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 }))
|
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 }))
|
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
|
// 6/8 time signature - 6 beats per bar
|
||||||
expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 }))
|
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 }))
|
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', () => {
|
it('should handle different BPM values correctly', () => {
|
||||||
// 60 BPM - 1 beat per second
|
// 60 BPM - 1 beat per second
|
||||||
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
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 }))
|
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
|
// 240 BPM - 4 beats per second
|
||||||
expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 }))
|
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 }))
|
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', () => {
|
it('should handle fractional beats correctly', () => {
|
||||||
expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 }))
|
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 }))
|
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 }))
|
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', () => {
|
it('should pad numbers correctly in output format', () => {
|
||||||
// Test bar padding (BBB format)
|
// Test bar padding (BBB format)
|
||||||
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
|
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
|
||||||
.toMatch(/^001:/)
|
.toMatch(/^001:/);
|
||||||
|
|
||||||
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
|
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
|
||||||
.toMatch(/^011:/) // Bar 11
|
.toMatch(/^011:/); // Bar 11
|
||||||
|
|
||||||
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
|
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
|
||||||
.toMatch(/^100:/) // Bar 100
|
.toMatch(/^100:/); // Bar 100
|
||||||
|
|
||||||
// Test time padding (mm:ss:mmm format)
|
// Test time padding (mm:ss:mmm format)
|
||||||
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
||||||
.toMatch(/\| 00:01:000$/)
|
.toMatch(/\| 00:01:000$/);
|
||||||
|
|
||||||
expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 }))
|
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', () => {
|
it('should handle large beat values', () => {
|
||||||
const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 })
|
const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 });
|
||||||
expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/)
|
expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle edge case of zero BPM gracefully', () => {
|
it('should handle edge case of zero BPM gracefully', () => {
|
||||||
// This might cause division by zero, should handle gracefully
|
// This might cause division by zero, should handle gracefully
|
||||||
expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 }))
|
expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 }))
|
||||||
.not.toThrow()
|
.not.toThrow();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle negative beats', () => {
|
it('should handle negative beats', () => {
|
||||||
// Edge case - negative beats may produce negative values in time format
|
// Edge case - negative beats may produce negative values in time format
|
||||||
const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 })
|
const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 });
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined();
|
||||||
expect(typeof result).toBe('string')
|
expect(typeof result).toBe('string');
|
||||||
// The function may produce negative time values for negative beats
|
// 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', () => {
|
describe('formatLocalDateTime', () => {
|
||||||
let mockDate: Date
|
let mockDate: Date;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Use a fixed date for consistent testing
|
// 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', () => {
|
it('should format date with correct structure', () => {
|
||||||
const result = formatLocalDateTime(mockDate)
|
const result = formatLocalDateTime(mockDate);
|
||||||
|
|
||||||
// Should contain date parts
|
// Should contain date parts
|
||||||
expect(result).toMatch(/\d{4}/) // Year
|
expect(result).toMatch(/\d{4}/); // Year
|
||||||
expect(result).toMatch(/\d{2}/) // Month/day/hour/minute/second
|
expect(result).toMatch(/\d{2}/); // Month/day/hour/minute/second
|
||||||
expect(result).toContain(':') // Time separator
|
expect(result).toContain(':'); // Time separator
|
||||||
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/) // Timezone
|
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/); // Timezone
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should use 24-hour format', () => {
|
it('should use 24-hour format', () => {
|
||||||
const morningDate = new Date('2025-08-21T09:30:00Z')
|
const morningDate = new Date('2025-08-21T09:30:00Z');
|
||||||
const eveningDate = new Date('2025-08-21T21:30:00Z')
|
const eveningDate = new Date('2025-08-21T21:30:00Z');
|
||||||
|
|
||||||
const morningResult = formatLocalDateTime(morningDate)
|
const morningResult = formatLocalDateTime(morningDate);
|
||||||
const eveningResult = formatLocalDateTime(eveningDate)
|
const eveningResult = formatLocalDateTime(eveningDate);
|
||||||
|
|
||||||
// Should not contain AM/PM indicators
|
// Should not contain AM/PM indicators
|
||||||
expect(morningResult).not.toMatch(/AM|PM/i)
|
expect(morningResult).not.toMatch(/AM|PM/i);
|
||||||
expect(eveningResult).not.toMatch(/AM|PM/i)
|
expect(eveningResult).not.toMatch(/AM|PM/i);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should include timezone information', () => {
|
it('should include timezone information', () => {
|
||||||
const result = formatLocalDateTime(mockDate)
|
const result = formatLocalDateTime(mockDate);
|
||||||
|
|
||||||
// Should contain some timezone indicator
|
// 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', () => {
|
it('should handle different dates consistently', () => {
|
||||||
const dates = [
|
const dates = [
|
||||||
new Date('2025-01-01T00:00:00Z'),
|
new Date('2025-01-01T00:00:00Z'),
|
||||||
new Date('2025-06-15T12:30:45Z'),
|
new Date('2025-06-15T12:30:45Z'),
|
||||||
new Date('2025-12-31T23:59:59Z')
|
new Date('2025-12-31T23:59:59Z')
|
||||||
]
|
];
|
||||||
|
|
||||||
dates.forEach(date => {
|
dates.forEach(date => {
|
||||||
const result = formatLocalDateTime(date)
|
const result = formatLocalDateTime(date);
|
||||||
expect(result).toBeDefined()
|
expect(result).toBeDefined();
|
||||||
expect(typeof result).toBe('string')
|
expect(typeof result).toBe('string');
|
||||||
expect(result.length).toBeGreaterThan(10)
|
expect(result.length).toBeGreaterThan(10);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle edge dates', () => {
|
it('should handle edge dates', () => {
|
||||||
const edgeDates = [
|
const edgeDates = [
|
||||||
new Date('1970-01-01T00:00:00Z'), // Unix epoch
|
new Date('1970-01-01T00:00:00Z'), // Unix epoch
|
||||||
new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit
|
new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit
|
||||||
new Date('2100-12-31T23:59:59Z') // Future date
|
new Date('2100-12-31T23:59:59Z') // Future date
|
||||||
]
|
];
|
||||||
|
|
||||||
edgeDates.forEach(date => {
|
edgeDates.forEach(date => {
|
||||||
expect(() => formatLocalDateTime(date)).not.toThrow()
|
expect(() => formatLocalDateTime(date)).not.toThrow();
|
||||||
const result = formatLocalDateTime(date)
|
const result = formatLocalDateTime(date);
|
||||||
expect(typeof result).toBe('string')
|
expect(typeof result).toBe('string');
|
||||||
expect(result.length).toBeGreaterThan(0)
|
expect(result.length).toBeGreaterThan(0);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should be consistent for the same date', () => {
|
it('should be consistent for the same date', () => {
|
||||||
const result1 = formatLocalDateTime(mockDate)
|
const result1 = formatLocalDateTime(mockDate);
|
||||||
const result2 = formatLocalDateTime(mockDate)
|
const result2 = formatLocalDateTime(mockDate);
|
||||||
|
|
||||||
expect(result1).toBe(result2)
|
expect(result1).toBe(result2);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle leap year dates', () => {
|
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()
|
expect(() => formatLocalDateTime(leapYearDate)).not.toThrow();
|
||||||
const result = formatLocalDateTime(leapYearDate)
|
const result = formatLocalDateTime(leapYearDate);
|
||||||
expect(result).toContain('2024')
|
expect(result).toContain('2024');
|
||||||
expect(result).toContain('02')
|
expect(result).toContain('02');
|
||||||
expect(result).toContain('29')
|
expect(result).toContain('29');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('integration tests', () => {
|
describe('integration tests', () => {
|
||||||
it('should work together for typical DAW workflow', () => {
|
it('should work together for typical DAW workflow', () => {
|
||||||
// Parse time signature
|
// Parse time signature
|
||||||
const timeSignature = parseTimeSignature('4/4')
|
const timeSignature = parseTimeSignature('4/4');
|
||||||
expect(timeSignature).not.toBeNull()
|
expect(timeSignature).not.toBeNull();
|
||||||
|
|
||||||
// Use parsed time signature in time formatting
|
// Use parsed time signature in time formatting
|
||||||
const timeString = beatsToTimeString(16, 120, timeSignature!)
|
const timeString = beatsToTimeString(16, 120, timeSignature!);
|
||||||
expect(timeString).toBe('005:1 | 00:08:000')
|
expect(timeString).toBe('005:1 | 00:08:000');
|
||||||
|
|
||||||
// Format current time
|
// Format current time
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const formattedTime = formatLocalDateTime(now)
|
const formattedTime = formatLocalDateTime(now);
|
||||||
expect(formattedTime).toBeDefined()
|
expect(formattedTime).toBeDefined();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle error cases gracefully in workflow', () => {
|
it('should handle error cases gracefully in workflow', () => {
|
||||||
// Invalid time signature should not break workflow
|
// Invalid time signature should not break workflow
|
||||||
const invalidTimeSignature = parseTimeSignature('invalid')
|
const invalidTimeSignature = parseTimeSignature('invalid');
|
||||||
expect(invalidTimeSignature).toBeNull()
|
expect(invalidTimeSignature).toBeNull();
|
||||||
|
|
||||||
// Get error message for user feedback
|
// Get error message for user feedback
|
||||||
const errorMessage = getTimeSignatureErrorMessage()
|
const errorMessage = getTimeSignatureErrorMessage();
|
||||||
expect(errorMessage).toContain('Invalid time signature')
|
expect(errorMessage).toContain('Invalid time signature');
|
||||||
|
|
||||||
// Fallback to default time signature
|
// Fallback to default time signature
|
||||||
const fallbackTimeSignature = { numerator: 4, denominator: 4 }
|
const fallbackTimeSignature = { numerator: 4, denominator: 4 };
|
||||||
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature)
|
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature);
|
||||||
expect(timeString).toBe('003:1 | 00:04:000')
|
expect(timeString).toBe('003:1 | 00:04:000');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
+204
-204
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest';
|
||||||
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil'
|
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil';
|
||||||
import {
|
import {
|
||||||
longTextWithAddNotes,
|
longTextWithAddNotes,
|
||||||
longTextWithReadMusic,
|
longTextWithReadMusic,
|
||||||
@@ -9,323 +9,323 @@ import {
|
|||||||
malformedXml,
|
malformedXml,
|
||||||
noXmlContent,
|
noXmlContent,
|
||||||
emptyAndWhitespaceXml
|
emptyAndWhitespaceXml
|
||||||
} from '../test/fixtures/xml-samples'
|
} from '../test/fixtures/xml-samples';
|
||||||
|
|
||||||
describe('xmlUtil', () => {
|
describe('xmlUtil', () => {
|
||||||
describe('extractXMLFromString', () => {
|
describe('extractXMLFromString', () => {
|
||||||
it('should extract simple XML blocks', () => {
|
it('should extract simple XML blocks', () => {
|
||||||
const input = `Here is some text with <test>content</test> and more text.`
|
const input = `Here is some text with <test>content</test> and more text.`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('<test>content</test>')
|
expect(result[0]).toBe('<test>content</test>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract multiple XML blocks', () => {
|
it('should extract multiple XML blocks', () => {
|
||||||
const input = `<first>content1</first> some text <second>content2</second>`
|
const input = `<first>content1</first> some text <second>content2</second>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0]).toBe('<first>content1</first>')
|
expect(result[0]).toBe('<first>content1</first>');
|
||||||
expect(result[1]).toBe('<second>content2</second>')
|
expect(result[1]).toBe('<second>content2</second>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract XML blocks with nested elements', () => {
|
it('should extract XML blocks with nested elements', () => {
|
||||||
const input = `<outer><inner>nested content</inner></outer>`
|
const input = `<outer><inner>nested content</inner></outer>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>')
|
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract XML blocks with attributes', () => {
|
it('should extract XML blocks with attributes', () => {
|
||||||
const input = `<tag attr="value" id="123">content</tag>`
|
const input = `<tag attr="value" id="123">content</tag>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>')
|
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle multiline XML blocks', () => {
|
it('should handle multiline XML blocks', () => {
|
||||||
const input = `<multiline>
|
const input = `<multiline>
|
||||||
<line1>content1</line1>
|
<line1>content1</line1>
|
||||||
<line2>content2</line2>
|
<line2>content2</line2>
|
||||||
</multiline>`
|
</multiline>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toContain('<multiline>')
|
expect(result[0]).toContain('<multiline>');
|
||||||
expect(result[0]).toContain('<line1>content1</line1>')
|
expect(result[0]).toContain('<line1>content1</line1>');
|
||||||
expect(result[0]).toContain('<line2>content2</line2>')
|
expect(result[0]).toContain('<line2>content2</line2>');
|
||||||
expect(result[0]).toContain('</multiline>')
|
expect(result[0]).toContain('</multiline>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract XML from complex nested content fixture', () => {
|
it('should extract XML from complex nested content fixture', () => {
|
||||||
const result = extractXMLFromString(nestedXmlContent)
|
const result = extractXMLFromString(nestedXmlContent);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toContain('<container>')
|
expect(result[0]).toContain('<container>');
|
||||||
expect(result[0]).toContain('<inner_element>')
|
expect(result[0]).toContain('<inner_element>');
|
||||||
expect(result[0]).toContain('<deep_nested>')
|
expect(result[0]).toContain('<deep_nested>');
|
||||||
expect(result[0]).toContain('<value>Test Content</value>')
|
expect(result[0]).toContain('<value>Test Content</value>');
|
||||||
expect(result[0]).toContain('</container>')
|
expect(result[0]).toContain('</container>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract multiple XML blocks from fixture', () => {
|
it('should extract multiple XML blocks from fixture', () => {
|
||||||
const result = extractXMLFromString(multipleXmlBlocks)
|
const result = extractXMLFromString(multipleXmlBlocks);
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
expect(result).toHaveLength(3);
|
||||||
expect(result[0]).toContain('<add_notes>')
|
expect(result[0]).toContain('<add_notes>');
|
||||||
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[1]).toContain('</read_music>')
|
expect(result[1]).toContain('</read_music>');
|
||||||
expect(result[2]).toContain('<modify_tempo>')
|
expect(result[2]).toContain('<modify_tempo>');
|
||||||
expect(result[2]).toContain('</modify_tempo>')
|
expect(result[2]).toContain('</modify_tempo>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should extract XML with attributes from fixture', () => {
|
it('should extract XML with attributes from fixture', () => {
|
||||||
const result = extractXMLFromString(xmlWithAttributes)
|
const result = extractXMLFromString(xmlWithAttributes);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toContain('region_id="main"')
|
expect(result[0]).toContain('region_id="main"');
|
||||||
expect(result[0]).toContain('track="melody"')
|
expect(result[0]).toContain('track="melody"');
|
||||||
expect(result[0]).toContain('id="1"')
|
expect(result[0]).toContain('id="1"');
|
||||||
expect(result[0]).toContain('velocity="127"')
|
expect(result[0]).toContain('velocity="127"');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle the long text with add_notes fixture (Case 1)', () => {
|
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
|
// Find the add_notes block
|
||||||
const addNotesBlock = result.find(block => block.includes('<add_notes>'))
|
const addNotesBlock = result.find(block => block.includes('<add_notes>'));
|
||||||
expect(addNotesBlock).toBeDefined()
|
expect(addNotesBlock).toBeDefined();
|
||||||
expect(addNotesBlock).toContain('<notes>')
|
expect(addNotesBlock).toContain('<notes>');
|
||||||
expect(addNotesBlock).toContain('<note>')
|
expect(addNotesBlock).toContain('<note>');
|
||||||
expect(addNotesBlock).toContain('<pitch>C4</pitch>')
|
expect(addNotesBlock).toContain('<pitch>C4</pitch>');
|
||||||
expect(addNotesBlock).toContain('<start_beat>0</start_beat>')
|
expect(addNotesBlock).toContain('<start_beat>0</start_beat>');
|
||||||
expect(addNotesBlock).toContain('<length>4</length>')
|
expect(addNotesBlock).toContain('<length>4</length>');
|
||||||
expect(addNotesBlock).toContain('</add_notes>')
|
expect(addNotesBlock).toContain('</add_notes>');
|
||||||
|
|
||||||
// Should contain all 12 notes
|
// Should contain all 12 notes
|
||||||
const noteMatches = addNotesBlock!.match(/<note>/g)
|
const noteMatches = addNotesBlock!.match(/<note>/g);
|
||||||
expect(noteMatches).toHaveLength(12)
|
expect(noteMatches).toHaveLength(12);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle the long text with read_music fixture (Case 2)', () => {
|
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)
|
// First XML block (inside thinking tag)
|
||||||
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('<start_beat>0</start_beat>');
|
||||||
expect(result[0]).toContain('<length>32</length>')
|
expect(result[0]).toContain('<length>32</length>');
|
||||||
expect(result[0]).toContain('</read_music>')
|
expect(result[0]).toContain('</read_music>');
|
||||||
|
|
||||||
// Second XML block (at the end)
|
// Second XML block (at the end)
|
||||||
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('<start_beat>0</start_beat>');
|
||||||
expect(result[1]).toContain('<length>32</length>')
|
expect(result[1]).toContain('<length>32</length>');
|
||||||
expect(result[1]).toContain('</read_music>')
|
expect(result[1]).toContain('</read_music>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle malformed XML gracefully', () => {
|
it('should handle malformed XML gracefully', () => {
|
||||||
const result = extractXMLFromString(malformedXml)
|
const result = extractXMLFromString(malformedXml);
|
||||||
|
|
||||||
// Should extract valid XML blocks (ignores malformed ones)
|
// Should extract valid XML blocks (ignores malformed ones)
|
||||||
expect(result.length).toBeGreaterThanOrEqual(1)
|
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Find the definitely valid block
|
// Find the definitely valid block
|
||||||
const validBlock = result.find(block => block.includes('<valid_tag>'))
|
const validBlock = result.find(block => block.includes('<valid_tag>'));
|
||||||
expect(validBlock).toBeDefined()
|
expect(validBlock).toBeDefined();
|
||||||
expect(validBlock).toContain('<content>This is valid</content>')
|
expect(validBlock).toContain('<content>This is valid</content>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return empty array for content with no XML', () => {
|
it('should return empty array for content with no XML', () => {
|
||||||
const result = extractXMLFromString(noXmlContent)
|
const result = extractXMLFromString(noXmlContent);
|
||||||
|
|
||||||
expect(result).toHaveLength(0)
|
expect(result).toHaveLength(0);
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([]);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle empty and whitespace XML', () => {
|
it('should handle empty and whitespace XML', () => {
|
||||||
const result = extractXMLFromString(emptyAndWhitespaceXml)
|
const result = extractXMLFromString(emptyAndWhitespaceXml);
|
||||||
|
|
||||||
expect(result).toHaveLength(3)
|
expect(result).toHaveLength(3);
|
||||||
expect(result[0]).toBe('<empty_tag></empty_tag>')
|
expect(result[0]).toBe('<empty_tag></empty_tag>');
|
||||||
expect(result[1]).toContain('<whitespace_tag>')
|
expect(result[1]).toContain('<whitespace_tag>');
|
||||||
expect(result[1]).toContain('</whitespace_tag>')
|
expect(result[1]).toContain('</whitespace_tag>');
|
||||||
expect(result[2]).toContain('<mixed_content>')
|
expect(result[2]).toContain('<mixed_content>');
|
||||||
expect(result[2]).toContain('Some text with spaces')
|
expect(result[2]).toContain('Some text with spaces');
|
||||||
expect(result[2]).toContain('</mixed_content>')
|
expect(result[2]).toContain('</mixed_content>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle XML with underscores and hyphens in tag names', () => {
|
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 input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0]).toBe('<tag_name>content</tag_name>')
|
expect(result[0]).toBe('<tag_name>content</tag_name>');
|
||||||
expect(result[1]).toBe('<tag-name>content</tag-name>')
|
expect(result[1]).toBe('<tag-name>content</tag-name>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle self-closing tags (not currently supported)', () => {
|
it('should handle self-closing tags (not currently supported)', () => {
|
||||||
const input = `<self-closing /> and <normal>content</normal>`
|
const input = `<self-closing /> and <normal>content</normal>`;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
// Current implementation doesn't support self-closing tags
|
// Current implementation doesn't support self-closing tags
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('<normal>content</normal>')
|
expect(result[0]).toBe('<normal>content</normal>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should trim whitespace around extracted XML', () => {
|
it('should trim whitespace around extracted XML', () => {
|
||||||
const input = ` <tag>content</tag> `
|
const input = ` <tag>content</tag> `;
|
||||||
const result = extractXMLFromString(input)
|
const result = extractXMLFromString(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0]).toBe('<tag>content</tag>')
|
expect(result[0]).toBe('<tag>content</tag>');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('wrapXmlBlocksInContent', () => {
|
describe('wrapXmlBlocksInContent', () => {
|
||||||
it('should wrap single XML block in fenced code block', () => {
|
it('should wrap single XML block in fenced code block', () => {
|
||||||
const input = `Here is <test>content</test> in text.`
|
const input = `Here is <test>content</test> in text.`;
|
||||||
const result = wrapXmlBlocksInContent(input)
|
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', () => {
|
it('should wrap multiple XML blocks', () => {
|
||||||
const input = `<first>content1</first> text <second>content2</second>`
|
const input = `<first>content1</first> text <second>content2</second>`;
|
||||||
const result = wrapXmlBlocksInContent(input)
|
const result = wrapXmlBlocksInContent(input);
|
||||||
|
|
||||||
expect(result).toContain('```xml\n<first>content1</first>\n```')
|
expect(result).toContain('```xml\n<first>content1</first>\n```');
|
||||||
expect(result).toContain('```xml\n<second>content2</second>\n```')
|
expect(result).toContain('```xml\n<second>content2</second>\n```');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should return original content when no XML blocks present', () => {
|
it('should return original content when no XML blocks present', () => {
|
||||||
const input = noXmlContent
|
const input = noXmlContent;
|
||||||
const result = wrapXmlBlocksInContent(input)
|
const result = wrapXmlBlocksInContent(input);
|
||||||
|
|
||||||
expect(result).toBe(input)
|
expect(result).toBe(input);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle empty input', () => {
|
it('should handle empty input', () => {
|
||||||
expect(wrapXmlBlocksInContent('')).toBe('')
|
expect(wrapXmlBlocksInContent('')).toBe('');
|
||||||
expect(wrapXmlBlocksInContent(null as any)).toBeNull()
|
expect(wrapXmlBlocksInContent(null as unknown as string)).toBeNull();
|
||||||
expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined()
|
expect(wrapXmlBlocksInContent(undefined as unknown as string)).toBeUndefined();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should wrap XML blocks from multipleXmlBlocks fixture', () => {
|
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('```xml\n<add_notes>');
|
||||||
expect(result).toContain('</add_notes>\n```')
|
expect(result).toContain('</add_notes>\n```');
|
||||||
expect(result).toContain('```xml\n<read_music>')
|
expect(result).toContain('```xml\n<read_music>');
|
||||||
expect(result).toContain('</read_music>\n```')
|
expect(result).toContain('</read_music>\n```');
|
||||||
expect(result).toContain('```xml\n<modify_tempo>')
|
expect(result).toContain('```xml\n<modify_tempo>');
|
||||||
expect(result).toContain('</modify_tempo>\n```')
|
expect(result).toContain('</modify_tempo>\n```');
|
||||||
|
|
||||||
// Should preserve the surrounding text
|
// Should preserve the surrounding text
|
||||||
expect(result).toContain('Here\'s how to add multiple musical elements:')
|
expect(result).toContain('Here\'s how to add multiple musical elements:');
|
||||||
expect(result).toContain('First, let\'s add some notes:')
|
expect(result).toContain('First, let\'s add some notes:');
|
||||||
expect(result).toContain('Then we can read the current music:')
|
expect(result).toContain('Then we can read the current music:');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should wrap the long add_notes XML block (Case 1)', () => {
|
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('```xml\n<add_notes>');
|
||||||
expect(result).toContain('</add_notes>\n```')
|
expect(result).toContain('</add_notes>\n```');
|
||||||
|
|
||||||
// Should preserve the thinking content and other text
|
// Should preserve the thinking content and other text
|
||||||
expect(result).toContain('<thinking>')
|
expect(result).toContain('<thinking>');
|
||||||
expect(result).toContain('Perfect! I can see this is a beautiful')
|
expect(result).toContain('Perfect! I can see this is a beautiful');
|
||||||
expect(result).toContain('Let me start by adding the harmony')
|
expect(result).toContain('Let me start by adding the harmony');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should wrap the long read_music XML blocks (Case 2)', () => {
|
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
|
// Should contain two wrapped XML blocks
|
||||||
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g)
|
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g);
|
||||||
expect(fencedBlocks).toHaveLength(2)
|
expect(fencedBlocks).toHaveLength(2);
|
||||||
|
|
||||||
// Should preserve the thinking content and other text
|
// Should preserve the thinking content and other text
|
||||||
expect(result).toContain('<thinking>')
|
expect(result).toContain('<thinking>');
|
||||||
expect(result).toContain('I\'ll help you create a pad harmony track')
|
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('Let me first read the existing music');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle nested XML correctly', () => {
|
it('should handle nested XML correctly', () => {
|
||||||
const result = wrapXmlBlocksInContent(nestedXmlContent)
|
const result = wrapXmlBlocksInContent(nestedXmlContent);
|
||||||
|
|
||||||
expect(result).toContain('```xml\n<container>')
|
expect(result).toContain('```xml\n<container>');
|
||||||
expect(result).toContain('<inner_element>')
|
expect(result).toContain('<inner_element>');
|
||||||
expect(result).toContain('<deep_nested>')
|
expect(result).toContain('<deep_nested>');
|
||||||
expect(result).toContain('<value>Test Content</value>')
|
expect(result).toContain('<value>Test Content</value>');
|
||||||
expect(result).toContain('</container>\n```')
|
expect(result).toContain('</container>\n```');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should preserve XML block integrity when wrapping', () => {
|
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 input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`;
|
||||||
const result = wrapXmlBlocksInContent(input)
|
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', () => {
|
it('should handle duplicate XML blocks correctly', () => {
|
||||||
const input = `<same>content</same> and then <same>content</same> again`
|
const input = `<same>content</same> and then <same>content</same> again`;
|
||||||
const result = wrapXmlBlocksInContent(input)
|
const result = wrapXmlBlocksInContent(input);
|
||||||
|
|
||||||
// Both instances should be wrapped
|
// Both instances should be wrapped
|
||||||
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g)
|
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g);
|
||||||
expect(wrappedBlocks).toHaveLength(2)
|
expect(wrappedBlocks).toHaveLength(2);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('edge cases and error handling', () => {
|
describe('edge cases and error handling', () => {
|
||||||
it('should handle very large XML blocks', () => {
|
it('should handle very large XML blocks', () => {
|
||||||
const largeContent = 'x'.repeat(10000)
|
const largeContent = 'x'.repeat(10000);
|
||||||
const input = `<large>${largeContent}</large>`
|
const input = `<large>${largeContent}</large>`;
|
||||||
|
|
||||||
const extracted = extractXMLFromString(input)
|
const extracted = extractXMLFromString(input);
|
||||||
expect(extracted).toHaveLength(1)
|
expect(extracted).toHaveLength(1);
|
||||||
expect(extracted[0]).toContain(largeContent)
|
expect(extracted[0]).toContain(largeContent);
|
||||||
|
|
||||||
const wrapped = wrapXmlBlocksInContent(input)
|
const wrapped = wrapXmlBlocksInContent(input);
|
||||||
expect(wrapped).toContain('```xml\n<large>')
|
expect(wrapped).toContain('```xml\n<large>');
|
||||||
expect(wrapped).toContain('</large>\n```')
|
expect(wrapped).toContain('</large>\n```');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle XML with special characters', () => {
|
it('should handle XML with special characters', () => {
|
||||||
const input = `<tag>Content with < > & " '</tag>`
|
const input = `<tag>Content with < > & " '</tag>`;
|
||||||
|
|
||||||
const extracted = extractXMLFromString(input)
|
const extracted = extractXMLFromString(input);
|
||||||
expect(extracted).toHaveLength(1)
|
expect(extracted).toHaveLength(1);
|
||||||
expect(extracted[0]).toContain('< > & " '')
|
expect(extracted[0]).toContain('< > & " '');
|
||||||
|
|
||||||
const wrapped = wrapXmlBlocksInContent(input)
|
const wrapped = wrapXmlBlocksInContent(input);
|
||||||
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```')
|
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle XML with CDATA sections', () => {
|
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)
|
const extracted = extractXMLFromString(input);
|
||||||
expect(extracted).toHaveLength(1)
|
expect(extracted).toHaveLength(1);
|
||||||
expect(extracted[0]).toContain('<![CDATA[')
|
expect(extracted[0]).toContain('<![CDATA[');
|
||||||
expect(extracted[0]).toContain(']]>')
|
expect(extracted[0]).toContain(']]>');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('should handle mixed content with partial XML-like text', () => {
|
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)
|
const extracted = extractXMLFromString(input);
|
||||||
expect(extracted).toHaveLength(1)
|
expect(extracted).toHaveLength(1);
|
||||||
expect(extracted[0]).toBe('<valid>content</valid>')
|
expect(extracted[0]).toBe('<valid>content</valid>');
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user