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",
|
||||
"play": "space",
|
||||
"loop": "c",
|
||||
"record": "r",
|
||||
"undo": "ctrl+z",
|
||||
"redo": "ctrl+shift+z",
|
||||
"select_all": "ctrl+a",
|
||||
@@ -73,7 +74,8 @@
|
||||
"audio": {
|
||||
"enable_audio_capture_for_screen_sharing": false,
|
||||
"lookahead_time": 0.05,
|
||||
"playback_delay": 0.2
|
||||
"playback_delay": 0.2,
|
||||
"recording_offset": 0
|
||||
},
|
||||
"templates": {
|
||||
"custom_instructions": ""
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer';
|
||||
import { KGCore } from './core/KGCore';
|
||||
import { ConfigManager } from './core/config/ConfigManager';
|
||||
import { validateFunctionalChordsJSON } from './util/scaleUtil';
|
||||
import { showAlert } from './components/common/DialogProvider';
|
||||
import { showAlert } from './util/dialogUtil';
|
||||
import { KGProjectStorage } from './core/io/KGProjectStorage';
|
||||
import { RESERVED_PROJECT_NAME } from './util/projectNameUtil';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { sliceAudioToWav } from '../util/audioUtil';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import { ImportStemsCommand } from '../core/commands';
|
||||
import type { StemImportEntry } from '../core/commands';
|
||||
import { showAlert } from './common/DialogProvider';
|
||||
import { showAlert } from '../util/dialogUtil';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -300,7 +300,7 @@ const ClipTab: React.FC<ClipTabProps> = ({ bpm, keySignature }) => {
|
||||
setGenStatus('polling');
|
||||
setGenHint('Generating clip...');
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -626,7 +626,7 @@ const FullSongTab: React.FC = () => {
|
||||
type ResultItem = { progress: number; stage: string; status: number };
|
||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -997,7 +997,7 @@ const SeparatorTab: React.FC = () => {
|
||||
|
||||
let files: string[] = [];
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -1414,7 +1414,7 @@ const RemixTab: React.FC = () => {
|
||||
type ResultItem = { progress: number; stage: string; status: number };
|
||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -1932,7 +1932,7 @@ const RepaintTab: React.FC = () => {
|
||||
type ResultItem = { progress: number; stage: string; status: number };
|
||||
type PollResponse = { data: Array<{ status: number; result: string }>; code: number };
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) return;
|
||||
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
FaUndo, FaRedo, FaMousePointer, FaStepBackward,
|
||||
FaPlay, FaPause, FaComments, FaSync,
|
||||
FaFolderOpen, FaSave, FaDownload, FaUpload, FaPlus,
|
||||
FaCog, FaMagnet, FaCut
|
||||
FaCog, FaMagnet, FaCut, FaCircle
|
||||
} from 'react-icons/fa';
|
||||
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 { FaPencil, FaCopy, FaPaste, FaTrash, FaWandMagicSparkles } from 'react-icons/fa6';
|
||||
import { KGMainContentState } from '../core/state/KGMainContentState';
|
||||
@@ -31,7 +33,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
|
||||
import PianoIcon from './common/icons/PianoIcon';
|
||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './common/DialogProvider';
|
||||
import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil';
|
||||
|
||||
const Toolbar: React.FC = () => {
|
||||
const {
|
||||
@@ -45,6 +47,7 @@ const Toolbar: React.FC = () => {
|
||||
isLooping, toggleLoop,
|
||||
canUndo, canRedo, undoDescription, redoDescription, undo, redo,
|
||||
toggleChatBox, toggleSettings, toggleKGOnePanel, showKGOnePanel, cleanupProjectState, toggleMetronome, isMetronomeEnabled,
|
||||
isRecording, startRecording, stopRecording,
|
||||
// Piano roll state/actions
|
||||
showPianoRoll, setShowPianoRoll, activeRegionId, setActiveRegionId,
|
||||
// Selection state
|
||||
@@ -515,6 +518,11 @@ const Toolbar: React.FC = () => {
|
||||
console.log("Pause button clicked");
|
||||
}
|
||||
try {
|
||||
if (isRecording) {
|
||||
await stopRecording();
|
||||
setStatus("Recording stopped — notes committed");
|
||||
return;
|
||||
}
|
||||
await stopPlaying();
|
||||
} catch (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 (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
@@ -943,6 +991,14 @@ const Toolbar: React.FC = () => {
|
||||
) : (
|
||||
<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
|
||||
title="Loop"
|
||||
className={`tool-button ${isLooping ? 'active' : ''}`}
|
||||
|
||||
@@ -1,60 +1,8 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import './DialogProvider.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
export interface PromptOptions {
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface TimeSigResult {
|
||||
numerator: number;
|
||||
denominator: number;
|
||||
}
|
||||
|
||||
let _showAlertFn: ((message: string) => Promise<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);
|
||||
}
|
||||
import { registerDialogFns } from '../../util/dialogUtil';
|
||||
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
|
||||
|
||||
interface DialogInfo {
|
||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
|
||||
@@ -129,10 +77,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
const registered = useRef(false);
|
||||
if (!registered.current) {
|
||||
registered.current = true;
|
||||
_showAlertFn = openAlert;
|
||||
_showConfirmFn = openConfirm;
|
||||
_showPromptFn = openPrompt;
|
||||
_showTimeSigFn = openTimeSig;
|
||||
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
|
||||
}
|
||||
|
||||
if (!dialog) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import './FileImportModal.css';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { showAlert } from './DialogProvider';
|
||||
import { showAlert } from '../../util/dialogUtil';
|
||||
|
||||
interface FileImportModalProps {
|
||||
isVisible: boolean;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FaTimes, FaSortUp, FaSortDown, FaCopy, FaTrash, FaUndo } from 'react-ic
|
||||
import { KGProjectStorage, type ProjectMeta } from '../../core/io/KGProjectStorage';
|
||||
import { isValidProjectName } from '../../util/projectNameUtil';
|
||||
import './OpenProjectModal.css';
|
||||
import { showAlert, showConfirm, showPrompt } from './DialogProvider';
|
||||
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||
|
||||
interface OpenProjectModalProps {
|
||||
onClose: () => void;
|
||||
|
||||
@@ -3,5 +3,6 @@ export { default as Playhead } from './Playhead';
|
||||
export { default as FileImportModal } from './FileImportModal';
|
||||
export { default as LoadingOverlay } from './LoadingOverlay';
|
||||
export { default as OpenProjectModal } from './OpenProjectModal';
|
||||
export { default as DialogProvider, showAlert, showConfirm, showPrompt, showTimeSigPrompt } from './DialogProvider';
|
||||
export type { ConfirmOptions, PromptOptions, TimeSigResult } from './DialogProvider';
|
||||
export { default as DialogProvider } 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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { beatsToBar } from '../../util/midiUtil';
|
||||
import { UpdateRegionCommand } from '../../core/commands';
|
||||
import { getSuitableChords, noteNameToPitchClass } from '../../util/scaleUtil';
|
||||
import { showAlert, showPrompt } from '../common/DialogProvider';
|
||||
import { showAlert, showPrompt } from '../../util/dialogUtil';
|
||||
|
||||
interface PianoRollProps {
|
||||
onClose: () => void;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import PianoNote from './PianoNote';
|
||||
import PianoKeys from './PianoKeys';
|
||||
import PianoGridHeader from './PianoGridHeader';
|
||||
@@ -43,7 +44,11 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
}) => {
|
||||
// Get 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
|
||||
const {
|
||||
resizingNoteId,
|
||||
@@ -216,8 +221,26 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
});
|
||||
}, [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 (
|
||||
<div
|
||||
<div
|
||||
className="piano-roll-content"
|
||||
ref={contentRef}
|
||||
>
|
||||
@@ -239,6 +262,7 @@ const PianoRollContent: React.FC<PianoRollContentProps> = ({
|
||||
chordGuide={chordGuide}
|
||||
>
|
||||
{memoizedNotes}
|
||||
{recordingNoteOverlays}
|
||||
</PianoGrid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@ const BehaviorSettings: React.FC = () => {
|
||||
const [chatboxDefaultOpen, setChatboxDefaultOpen] = useState<boolean>(true);
|
||||
const [audioLookaheadTime, setAudioLookaheadTime] = useState<string>('50');
|
||||
const [playbackDelay, setPlaybackDelay] = useState<string>('200');
|
||||
const [recordingOffset, setRecordingOffset] = useState<string>('0');
|
||||
const [enableAudioCapture, setEnableAudioCapture] = useState<boolean>(false);
|
||||
const [lookaheadValidationErrors, setLookaheadValidationErrors] = useState<string[]>([]);
|
||||
const [playbackDelayValidationErrors, setPlaybackDelayValidationErrors] = useState<string[]>([]);
|
||||
const [recordingOffsetValidationErrors, setRecordingOffsetValidationErrors] = useState<string[]>([]);
|
||||
|
||||
const configManager = ConfigManager.instance();
|
||||
|
||||
@@ -26,6 +28,8 @@ const BehaviorSettings: React.FC = () => {
|
||||
setAudioLookaheadTime(((lookaheadTimeSeconds * 1000).toFixed(0)));
|
||||
const playbackDelaySeconds = (configManager.get('audio.playback_delay') as number) ?? 0.2;
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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 boolValue = value === 'yes';
|
||||
setEnableAudioCapture(boolValue);
|
||||
@@ -212,6 +238,33 @@ const BehaviorSettings: React.FC = () => {
|
||||
</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">
|
||||
<label className="settings-label">
|
||||
Capture Audio for Screen Sharing
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ConfigManager } from '../../../core/config/ConfigManager';
|
||||
import { validateFunctionalChordsJSON } from '../../../util/scaleUtil';
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { showAlert } from '../../common/DialogProvider';
|
||||
import { showAlert } from '../../../util/dialogUtil';
|
||||
|
||||
const ChordGuideSettings: React.FC = () => {
|
||||
const [chordDefinition, setChordDefinition] = useState<string>('');
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.track-info {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
padding: 15px;
|
||||
padding: 15px 5px 15px 15px;
|
||||
background-color: #2d2d2d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -155,6 +155,34 @@
|
||||
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"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGAudioRegion } from '../../core/region/KGAudioRegion';
|
||||
import { generateNewRegionName } from '../../util/miscUtil';
|
||||
import { KGAudioFileStorage } from '../../core/io/KGAudioFileStorage';
|
||||
import { showAlert } from '../common/DialogProvider';
|
||||
import { showAlert } from '../../util/dialogUtil';
|
||||
import { parseMidiFirstTrackNotes } from '../../util/midiUtil';
|
||||
import * as Tone from 'tone';
|
||||
|
||||
|
||||
@@ -12,7 +12,36 @@ import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import { DEBUG_MODE } from '../../constants/uiConstants';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { AUDIO_INTERFACE_CONSTANTS } from '../../constants/coreConstants';
|
||||
import { showAlert, showConfirm, showPrompt } from '../common/DialogProvider';
|
||||
import { showAlert, showConfirm, showPrompt } from '../../util/dialogUtil';
|
||||
|
||||
const UNITY_POS = 750;
|
||||
const SLIDER_MAX = 1000;
|
||||
|
||||
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 {
|
||||
track: KGTrack;
|
||||
index: number;
|
||||
@@ -56,6 +85,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
const settingsDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const suppressDragRef = useRef(false);
|
||||
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
|
||||
const isAdjustingVolumeRef = useRef(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
@@ -129,11 +161,10 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
const next = Number(e.target.value) / 100;
|
||||
const next = sliderToDb(Number(e.target.value));
|
||||
isAdjustingVolumeRef.current = true;
|
||||
setVolume(next);
|
||||
try {
|
||||
// Live preview: update audio only while sliding
|
||||
KGAudioInterface.instance().setTrackVolume(track.getId().toString(), next);
|
||||
} catch (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>) => {
|
||||
e.stopPropagation();
|
||||
const next = !muted;
|
||||
@@ -302,8 +370,9 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={Math.round(volume * 100)}
|
||||
max="1000"
|
||||
step="1"
|
||||
value={dbToSlider(volume)}
|
||||
onChange={handleVolumeChange}
|
||||
onMouseDown={(e) => { e.stopPropagation(); isAdjustingVolumeRef.current = true; }}
|
||||
onMouseUp={(e) => { e.stopPropagation(); commitVolumeChange(); }}
|
||||
@@ -314,12 +383,32 @@ const TrackInfoItem: React.FC<TrackInfoItemProps> = ({
|
||||
/>
|
||||
<button
|
||||
className="reset-volume"
|
||||
title="Reset volume"
|
||||
aria-label="Reset volume"
|
||||
title="Reset to 0 dB"
|
||||
aria-label="Reset volume to 0 dB"
|
||||
onClick={handleResetVolume}
|
||||
>
|
||||
↺
|
||||
</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>
|
||||
|
||||
@@ -63,7 +63,9 @@ export const KEY_SIGNATURE_MAP = {
|
||||
|
||||
export const AUDIO_INTERFACE_CONSTANTS = {
|
||||
DEFAULT_MASTER_VOLUME: 0.8,
|
||||
DEFAULT_TRACK_VOLUME: 0.8,
|
||||
DEFAULT_TRACK_VOLUME: 0, // 0 dB (unity gain)
|
||||
MIN_TRACK_VOLUME_DB: -60, // practical floor; displayed as −∞
|
||||
MAX_TRACK_VOLUME_DB: 12,
|
||||
};
|
||||
|
||||
export const SAMPLER_CONSTANTS = {
|
||||
|
||||
+14
-6
@@ -10,6 +10,10 @@ import { KGRegion } from './region/KGRegion';
|
||||
import { generateUniqueId } from '../util/miscUtil';
|
||||
import { KGCommand, KGCommandHistory } from './commands';
|
||||
|
||||
interface PlaybackStartOptions {
|
||||
preserveLoopPreroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGCore - Main application class for the DAW
|
||||
* Implements the singleton pattern for global access
|
||||
@@ -231,7 +235,7 @@ export class KGCore {
|
||||
return this.isPlaying;
|
||||
}
|
||||
|
||||
public async preparePlay(): Promise<void> {
|
||||
public async preparePlay(options?: PlaybackStartOptions): Promise<void> {
|
||||
try {
|
||||
const audioInterface = KGAudioInterface.instance();
|
||||
|
||||
@@ -239,7 +243,9 @@ export class KGCore {
|
||||
await audioInterface.startAudioContext();
|
||||
|
||||
// Prepare playback with current project and playhead position
|
||||
audioInterface.preparePlayback(this.currentProject, this.playheadPosition);
|
||||
audioInterface.preparePlayback(this.currentProject, this.playheadPosition, {
|
||||
allowStartBeforeLoopStart: options?.preserveLoopPreroll ?? false,
|
||||
});
|
||||
|
||||
// Sync BPM and transport settings
|
||||
audioInterface.setBpm(this.currentProject.getBpm());
|
||||
@@ -301,7 +307,7 @@ export class KGCore {
|
||||
}
|
||||
|
||||
// High-level playback control methods
|
||||
public async startPlaying(): Promise<void> {
|
||||
public async startPlaying(options?: PlaybackStartOptions): Promise<void> {
|
||||
// Handle loop mode initialization
|
||||
if (this.currentProject.getIsLooping()) {
|
||||
const [startBar, endBar] = this.currentProject.getLoopingRange();
|
||||
@@ -319,11 +325,13 @@ export class KGCore {
|
||||
const updatedRange = this.currentProject.getLoopingRange();
|
||||
const beatsPerBar = this.currentProject.getTimeSignature().numerator;
|
||||
const loopStartBeats = updatedRange[0] * beatsPerBar;
|
||||
this.setPlayheadPosition(loopStartBeats);
|
||||
if (!options?.preserveLoopPreroll) {
|
||||
this.setPlayheadPosition(loopStartBeats);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare playback first
|
||||
await this.preparePlay();
|
||||
await this.preparePlay(options);
|
||||
|
||||
// Start playing (non-blocking)
|
||||
this.play(); // Don't await this
|
||||
@@ -645,4 +653,4 @@ export class KGCore {
|
||||
public getCommandHistoryStats(): { undoCount: number; redoCount: number; maxSize: number } {
|
||||
return this.commandHistory.getHistoryStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+89
-91
@@ -513,8 +513,7 @@ export class KGDebugger {
|
||||
|
||||
try {
|
||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||
} catch {
|
||||
}
|
||||
} catch { /* setting cursor position can fail in read-only or special inputs */ }
|
||||
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
|
||||
@@ -583,8 +582,7 @@ export class KGDebugger {
|
||||
|
||||
try {
|
||||
textarea.selectionStart = textarea.selectionEnd = typed.length;
|
||||
} catch {
|
||||
}
|
||||
} catch { /* setting cursor position can fail in read-only or special inputs */ }
|
||||
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
|
||||
@@ -673,212 +671,212 @@ export class KGDebugger {
|
||||
* await KGDebugger.opfs('cat project.json')
|
||||
*/
|
||||
public async opfs(command: string): Promise<void> {
|
||||
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const cmd = parts[0]
|
||||
const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
|
||||
const cmd = parts[0];
|
||||
// Strip surrounding quotes from arguments
|
||||
const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ')
|
||||
const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ');
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'pwd':
|
||||
console.log('/' + this.opfsCwd.join('/'))
|
||||
break
|
||||
console.log('/' + this.opfsCwd.join('/'));
|
||||
break;
|
||||
|
||||
case 'ls':
|
||||
await this.opfsLs()
|
||||
break
|
||||
await this.opfsLs();
|
||||
break;
|
||||
|
||||
case 'cd':
|
||||
await this.opfsCd(arg)
|
||||
break
|
||||
await this.opfsCd(arg);
|
||||
break;
|
||||
|
||||
case 'cat':
|
||||
await this.opfsCat(arg)
|
||||
break
|
||||
await this.opfsCat(arg);
|
||||
break;
|
||||
|
||||
case 'dl':
|
||||
await this.opfsDl(arg)
|
||||
break
|
||||
await this.opfsDl(arg);
|
||||
break;
|
||||
|
||||
case 'rm':
|
||||
await this.opfsRm(arg)
|
||||
break
|
||||
await this.opfsRm(arg);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`opfs: command not found: ${cmd}`)
|
||||
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>')
|
||||
console.log(`opfs: command not found: ${cmd}`);
|
||||
console.log('Available commands: pwd, ls, cd <path>, cat <file>, dl <file>, rm <name>');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`opfs: ${error}`)
|
||||
console.error(`opfs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsResolveCwd(): Promise<FileSystemDirectoryHandle> {
|
||||
let dir = await navigator.storage.getDirectory()
|
||||
let dir = await navigator.storage.getDirectory();
|
||||
for (const segment of this.opfsCwd) {
|
||||
dir = await dir.getDirectoryHandle(segment)
|
||||
dir = await dir.getDirectoryHandle(segment);
|
||||
}
|
||||
return dir
|
||||
return dir;
|
||||
}
|
||||
|
||||
private async opfsLs(): Promise<void> {
|
||||
const dir = await this.opfsResolveCwd()
|
||||
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = []
|
||||
const dir = await this.opfsResolveCwd();
|
||||
const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [];
|
||||
|
||||
for await (const entry of dir.values()) {
|
||||
if (entry.kind === 'file') {
|
||||
const fileHandle = entry as FileSystemFileHandle
|
||||
const file = await fileHandle.getFile()
|
||||
const fileHandle = entry as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
entries.push({
|
||||
kind: 'file',
|
||||
name: entry.name,
|
||||
size: file.size,
|
||||
modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19),
|
||||
})
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
kind: 'dir',
|
||||
name: entry.name + '/',
|
||||
size: 0,
|
||||
modified: '-',
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
entries.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('(empty directory)')
|
||||
return
|
||||
console.log('(empty directory)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Print header
|
||||
const cwdPath = '/' + this.opfsCwd.join('/')
|
||||
console.log(`total ${entries.length} (${cwdPath})`)
|
||||
const cwdPath = '/' + this.opfsCwd.join('/');
|
||||
console.log(`total ${entries.length} (${cwdPath})`);
|
||||
|
||||
// Format like ls -lla
|
||||
const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4)
|
||||
const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4);
|
||||
for (const e of entries) {
|
||||
const typeChar = e.kind === 'dir' ? 'd' : '-'
|
||||
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'
|
||||
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen)
|
||||
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`)
|
||||
const typeChar = e.kind === 'dir' ? 'd' : '-';
|
||||
const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--';
|
||||
const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen);
|
||||
console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsCd(path: string): Promise<void> {
|
||||
if (!path || path === '') {
|
||||
// cd with no args goes to root
|
||||
this.opfsCwd = []
|
||||
return
|
||||
this.opfsCwd = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let segments: string[]
|
||||
let segments: string[];
|
||||
|
||||
if (path === '/') {
|
||||
this.opfsCwd = []
|
||||
return
|
||||
this.opfsCwd = [];
|
||||
return;
|
||||
} else if (path.startsWith('/')) {
|
||||
// Absolute path
|
||||
segments = path.split('/').filter(Boolean)
|
||||
segments = path.split('/').filter(Boolean);
|
||||
} else {
|
||||
// Relative path
|
||||
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)]
|
||||
segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)];
|
||||
}
|
||||
|
||||
// Resolve . and ..
|
||||
const resolved: string[] = []
|
||||
const resolved: string[] = [];
|
||||
for (const seg of segments) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '.') continue;
|
||||
if (seg === '..') {
|
||||
resolved.pop()
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(seg)
|
||||
resolved.push(seg);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the path exists
|
||||
let dir = await navigator.storage.getDirectory()
|
||||
let dir = await navigator.storage.getDirectory();
|
||||
for (const seg of resolved) {
|
||||
try {
|
||||
dir = await dir.getDirectoryHandle(seg)
|
||||
dir = await dir.getDirectoryHandle(seg);
|
||||
} catch {
|
||||
console.error(`opfs: cd: no such directory: ${path}`)
|
||||
return
|
||||
console.error(`opfs: cd: no such directory: ${path}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.opfsCwd = resolved
|
||||
this.opfsCwd = resolved;
|
||||
}
|
||||
|
||||
private async opfsCat(fileName: string): Promise<void> {
|
||||
if (!fileName) {
|
||||
console.error('opfs: cat: missing file name')
|
||||
return
|
||||
console.error('opfs: cat: missing file name');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = await this.opfsResolveCwd()
|
||||
const dir = await this.opfsResolveCwd();
|
||||
try {
|
||||
const fileHandle = await dir.getFileHandle(fileName)
|
||||
const file = await fileHandle.getFile()
|
||||
const text = await file.text()
|
||||
const fileHandle = await dir.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
|
||||
// Pretty-print JSON files
|
||||
if (fileName.endsWith('.json')) {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
console.log(JSON.stringify(parsed, null, 2))
|
||||
const parsed = JSON.parse(text);
|
||||
console.log(JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
console.log(text)
|
||||
console.log(text);
|
||||
}
|
||||
} else {
|
||||
console.log(text)
|
||||
console.log(text);
|
||||
}
|
||||
} catch {
|
||||
console.error(`opfs: cat: ${fileName}: No such file`)
|
||||
console.error(`opfs: cat: ${fileName}: No such file`);
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsDl(fileName: string): Promise<void> {
|
||||
if (!fileName) {
|
||||
console.error('opfs: dl: missing file name')
|
||||
return
|
||||
console.error('opfs: dl: missing file name');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = await this.opfsResolveCwd()
|
||||
const dir = await this.opfsResolveCwd();
|
||||
try {
|
||||
const fileHandle = await dir.getFileHandle(fileName)
|
||||
const file = await fileHandle.getFile()
|
||||
const url = URL.createObjectURL(file)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
console.log(`downloaded: ${fileName} (${file.size} bytes)`)
|
||||
const fileHandle = await dir.getFileHandle(fileName);
|
||||
const file = await fileHandle.getFile();
|
||||
const url = URL.createObjectURL(file);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
console.log(`downloaded: ${fileName} (${file.size} bytes)`);
|
||||
} catch {
|
||||
console.error(`opfs: dl: ${fileName}: No such file`)
|
||||
console.error(`opfs: dl: ${fileName}: No such file`);
|
||||
}
|
||||
}
|
||||
|
||||
private async opfsRm(name: string): Promise<void> {
|
||||
if (!name) {
|
||||
console.error('opfs: rm: missing file or directory name')
|
||||
return
|
||||
console.error('opfs: rm: missing file or directory name');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = await this.opfsResolveCwd()
|
||||
const dir = await this.opfsResolveCwd();
|
||||
try {
|
||||
await dir.removeEntry(name, { recursive: true })
|
||||
console.log(`removed: ${name}`)
|
||||
await dir.removeEntry(name, { recursive: true });
|
||||
console.log(`removed: ${name}`);
|
||||
} catch {
|
||||
console.error(`opfs: rm: ${name}: No such file or directory`)
|
||||
console.error(`opfs: rm: ${name}: No such file or directory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export class KGProject {
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 6;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 7;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
|
||||
@@ -283,9 +283,8 @@ export class KGAudioBus {
|
||||
*/
|
||||
private updateSamplerVolume(): void {
|
||||
try {
|
||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
} catch (error) {
|
||||
console.error(`Error updating volume for ${this.instrument}:`, error);
|
||||
}
|
||||
@@ -297,14 +296,8 @@ export class KGAudioBus {
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
let effectiveVolume = this.volume;
|
||||
if (this.muted) {
|
||||
effectiveVolume = 0;
|
||||
} else if (hasSoloedTracks && !this.solo) {
|
||||
effectiveVolume = 0;
|
||||
}
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.sampler.volume.value = volumeDb;
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.sampler.volume.value = isSilent ? -Infinity : this.volume;
|
||||
} catch (error) {
|
||||
console.error(`Error applying effective volume for ${this.instrument}:`, error);
|
||||
}
|
||||
|
||||
@@ -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 { KGMetronome } from './KGMetronome';
|
||||
|
||||
interface PreparePlaybackOptions {
|
||||
allowStartBeforeLoopStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KGAudioInterface - Audio engine interface for the DAW
|
||||
* Implements the singleton pattern for global audio management
|
||||
@@ -42,6 +46,10 @@ export class KGAudioInterface {
|
||||
private isPlaying: boolean = false;
|
||||
private masterVolume: number = AUDIO_INTERFACE_CONSTANTS.DEFAULT_MASTER_VOLUME;
|
||||
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
|
||||
private masterGain: Tone.Gain | null = null;
|
||||
@@ -387,9 +395,10 @@ export class KGAudioInterface {
|
||||
/**
|
||||
* Prepare playback by scheduling all MIDI events
|
||||
*/
|
||||
public preparePlayback(project: KGProject, startPosition: number): void {
|
||||
public preparePlayback(project: KGProject, startPosition: number, options?: PreparePlaybackOptions): void {
|
||||
// Clear any existing scheduled events
|
||||
this.clearScheduledEvents();
|
||||
this.clearDelayedTransportStart();
|
||||
|
||||
console.log("Preparing playback");
|
||||
|
||||
@@ -432,7 +441,7 @@ export class KGAudioInterface {
|
||||
console.log(`Loop mode enabled: bars [${startBar}, ${endBar}], beats [${scheduleStartBeat}, ${scheduleEndBeat}]`);
|
||||
|
||||
// Adjust start position to loop start if before loop range
|
||||
if (startPosition < scheduleStartBeat) {
|
||||
if (startPosition < scheduleStartBeat && !options?.allowStartBeforeLoopStart) {
|
||||
startPosition = scheduleStartBeat;
|
||||
}
|
||||
} else {
|
||||
@@ -440,8 +449,18 @@ export class KGAudioInterface {
|
||||
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)
|
||||
this.setTransportPosition(startPosition);
|
||||
this.setTransportPosition(Math.max(0, startPosition));
|
||||
|
||||
// Start metronome if enabled
|
||||
if (this.isMetronomeEnabled) {
|
||||
@@ -646,8 +665,19 @@ export class KGAudioInterface {
|
||||
if (!this.isAudioContextStarted) {
|
||||
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;
|
||||
|
||||
console.log('Audio playback started');
|
||||
@@ -662,6 +692,7 @@ export class KGAudioInterface {
|
||||
*/
|
||||
public stopPlayback(): void {
|
||||
try {
|
||||
this.clearDelayedTransportStart();
|
||||
Tone.Transport.stop();
|
||||
this.metronome.stop();
|
||||
|
||||
@@ -789,7 +820,8 @@ export class KGAudioInterface {
|
||||
public setTransportPosition(position: number): void {
|
||||
try {
|
||||
// Convert beats to Tone.js time format
|
||||
const toneTime = this.beatsToToneTime(position);
|
||||
const safePosition = Math.max(0, position);
|
||||
const toneTime = this.beatsToToneTime(safePosition);
|
||||
Tone.Transport.position = toneTime;
|
||||
console.log(`Set transport position to ${position} beats (${toneTime})`);
|
||||
} catch (error) {
|
||||
@@ -802,6 +834,14 @@ export class KGAudioInterface {
|
||||
*/
|
||||
public getTransportPosition(): number {
|
||||
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;
|
||||
return this.toneTimeToBeats(position);
|
||||
} catch (error) {
|
||||
@@ -973,6 +1013,17 @@ export class KGAudioInterface {
|
||||
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 =====
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,14 +200,8 @@ export class KGAudioPlayerBus {
|
||||
*/
|
||||
public applyEffectiveVolume(hasSoloedTracks: boolean): void {
|
||||
try {
|
||||
let effectiveVolume = this.volume;
|
||||
if (this.muted) {
|
||||
effectiveVolume = 0;
|
||||
} else if (hasSoloedTracks && !this.solo) {
|
||||
effectiveVolume = 0;
|
||||
}
|
||||
const volumeDb = effectiveVolume > 0 ? 20 * Math.log10(effectiveVolume) : -Infinity;
|
||||
this.gainNode.gain.value = Math.pow(10, volumeDb / 20);
|
||||
const isSilent = this.muted || (hasSoloedTracks && !this.solo) || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error applying effective volume for audio player bus:', error);
|
||||
}
|
||||
@@ -266,9 +260,8 @@ export class KGAudioPlayerBus {
|
||||
|
||||
private updateGainVolume(): void {
|
||||
try {
|
||||
const effectiveVolume = this.muted ? 0 : this.volume;
|
||||
// Convert linear volume to gain value
|
||||
this.gainNode.gain.value = effectiveVolume;
|
||||
const isSilent = this.muted || this.volume <= AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB;
|
||||
this.gainNode.gain.value = isSilent ? 0 : Math.pow(10, this.volume / 20);
|
||||
} catch (error) {
|
||||
console.error('Error updating gain volume:', error);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
private loop: Tone.Loop | null = null;
|
||||
private sampler: Tone.Sampler | null = null;
|
||||
private prerollTimeoutIds: number[] = [];
|
||||
|
||||
/**
|
||||
* Load the woodblock sampler. Called once from KGAudioInterface.initialize() —
|
||||
@@ -34,6 +35,8 @@ export class KGMetronome {
|
||||
|
||||
const ppq = Tone.Transport.PPQ;
|
||||
|
||||
this.schedulePrerollClicks(startPositionBeats, beatsPerBar, playbackDelay);
|
||||
|
||||
this.loop = new Tone.Loop((time) => {
|
||||
if (this.sampler?.loaded) {
|
||||
// Derive bar position from the exact Transport tick count at the
|
||||
@@ -52,6 +55,10 @@ export class KGMetronome {
|
||||
|
||||
/** Stop and dispose the loop only — sampler is kept alive for reuse. */
|
||||
stop(): void {
|
||||
const context = Tone.getContext();
|
||||
this.prerollTimeoutIds.forEach(timeoutId => context.clearTimeout(timeoutId));
|
||||
this.prerollTimeoutIds = [];
|
||||
|
||||
if (this.loop) {
|
||||
this.loop.dispose();
|
||||
this.loop = null;
|
||||
@@ -65,4 +72,25 @@ export class KGMetronome {
|
||||
this.sampler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePrerollClicks(startPositionBeats: number, beatsPerBar: number, playbackDelay: number): void {
|
||||
if (startPositionBeats >= 0 || !this.sampler?.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secondsPerBeat = 60 / Tone.Transport.bpm.value;
|
||||
const 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 { CreateNoteCommand } from './CreateNoteCommand'
|
||||
import { KGCore } from '../../KGCore'
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote'
|
||||
import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CreateNoteCommand } from './CreateNoteCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGMidiNote } from '../../midi/KGMidiNote';
|
||||
import { createMockProject, createMockMidiTrack, createMockMidiRegion } from '../../../test/utils/mock-data';
|
||||
|
||||
// Mock the KGCore singleton
|
||||
vi.mock('../../KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: vi.fn()
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock generateUniqueId utility
|
||||
vi.mock('../../../util/miscUtil', () => ({
|
||||
generateUniqueId: vi.fn().mockReturnValue('mock-note-id')
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import the mocked function properly
|
||||
const { generateUniqueId } = await import('../../../util/miscUtil')
|
||||
const { generateUniqueId } = await import('../../../util/miscUtil');
|
||||
|
||||
interface MockCore {
|
||||
getCurrentProject: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
describe('CreateNoteCommand', () => {
|
||||
let mockCore: MockCore
|
||||
let mockProject: ReturnType<typeof createMockProject>
|
||||
let mockTrack: ReturnType<typeof createMockMidiTrack>
|
||||
let mockRegion: ReturnType<typeof createMockMidiRegion>
|
||||
let command: CreateNoteCommand
|
||||
let mockCore: MockCore;
|
||||
let mockProject: ReturnType<typeof createMockProject>;
|
||||
let mockTrack: ReturnType<typeof createMockMidiTrack>;
|
||||
let mockRegion: ReturnType<typeof createMockMidiRegion>;
|
||||
let command: CreateNoteCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test data
|
||||
@@ -36,25 +36,25 @@ describe('CreateNoteCommand', () => {
|
||||
id: 'test-region',
|
||||
trackId: 'test-track',
|
||||
name: 'Test Region'
|
||||
})
|
||||
});
|
||||
|
||||
mockTrack = createMockMidiTrack({
|
||||
id: 1,
|
||||
name: 'Test Track',
|
||||
regions: [mockRegion]
|
||||
})
|
||||
});
|
||||
|
||||
mockProject = createMockProject({
|
||||
name: 'Test Project',
|
||||
tracks: [mockTrack]
|
||||
})
|
||||
});
|
||||
|
||||
// Mock KGCore methods
|
||||
mockCore = {
|
||||
getCurrentProject: vi.fn().mockReturnValue(mockProject)
|
||||
}
|
||||
};
|
||||
|
||||
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore)
|
||||
vi.mocked(KGCore.instance).mockReturnValue(mockCore as unknown as KGCore);
|
||||
|
||||
// Create command
|
||||
command = new CreateNoteCommand(
|
||||
@@ -63,135 +63,135 @@ describe('CreateNoteCommand', () => {
|
||||
1, // endBeat
|
||||
60, // pitch (middle C)
|
||||
80 // velocity
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create command with correct parameters', () => {
|
||||
const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id')
|
||||
const cmd = new CreateNoteCommand('region-1', 2, 4, 72, 100, 'custom-id');
|
||||
|
||||
expect(cmd).toBeInstanceOf(CreateNoteCommand)
|
||||
expect(cmd).toBeInstanceOf(CreateNoteCommand);
|
||||
// We can't directly test private properties, but we can test execution
|
||||
})
|
||||
});
|
||||
|
||||
it('should generate unique ID when not provided', () => {
|
||||
new CreateNoteCommand('region-1', 0, 1, 60, 80)
|
||||
new CreateNoteCommand('region-1', 0, 1, 60, 80);
|
||||
|
||||
// The generateUniqueId mock should have been called
|
||||
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote')
|
||||
})
|
||||
expect(generateUniqueId).toHaveBeenCalledWith('KGMidiNote');
|
||||
});
|
||||
|
||||
it('should use provided ID when given', () => {
|
||||
// Clear previous calls
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllMocks();
|
||||
|
||||
new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id')
|
||||
new CreateNoteCommand('region-1', 0, 1, 60, 80, 'my-custom-id');
|
||||
|
||||
// Should not call generateUniqueId when ID is provided
|
||||
expect(generateUniqueId).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(generateUniqueId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create a note in the target region', () => {
|
||||
// Mock the addNote method
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||
|
||||
// Execute the command
|
||||
command.execute()
|
||||
command.execute();
|
||||
|
||||
// Verify note was added
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the note has correct properties
|
||||
const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote
|
||||
expect(addedNote).toBeInstanceOf(KGMidiNote)
|
||||
expect(addedNote.getStartBeat()).toBe(0)
|
||||
expect(addedNote.getEndBeat()).toBe(1)
|
||||
expect(addedNote.getPitch()).toBe(60)
|
||||
expect(addedNote.getVelocity()).toBe(80)
|
||||
})
|
||||
const addedNote = addNoteSpy.mock.calls[0][0] as KGMidiNote;
|
||||
expect(addedNote).toBeInstanceOf(KGMidiNote);
|
||||
expect(addedNote.getStartBeat()).toBe(0);
|
||||
expect(addedNote.getEndBeat()).toBe(1);
|
||||
expect(addedNote.getPitch()).toBe(60);
|
||||
expect(addedNote.getVelocity()).toBe(80);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent region', () => {
|
||||
// Create command for non-existent region
|
||||
const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80)
|
||||
const badCommand = new CreateNoteCommand('non-existent-region', 0, 1, 60, 80);
|
||||
|
||||
// Should throw error for non-existent region
|
||||
expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found')
|
||||
})
|
||||
expect(() => badCommand.execute()).toThrow('MIDI region with ID non-existent-region not found');
|
||||
});
|
||||
|
||||
it('should store created note for undo operation', () => {
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||
|
||||
command.execute()
|
||||
command.execute();
|
||||
|
||||
// Note should be created and stored internally for undo
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo', () => {
|
||||
it('should remove the created note', () => {
|
||||
// Execute first to create the note
|
||||
command.execute()
|
||||
command.execute();
|
||||
|
||||
// Mock removeNote method
|
||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
|
||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
|
||||
|
||||
// Undo the command
|
||||
command.undo()
|
||||
command.undo();
|
||||
|
||||
// Verify note was removed
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw error when undoing without execute', () => {
|
||||
// Try to undo without executing first
|
||||
expect(() => command.undo()).toThrow('Cannot undo: no note was created')
|
||||
})
|
||||
})
|
||||
expect(() => command.undo()).toThrow('Cannot undo: no note was created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-execute (redo pattern)', () => {
|
||||
it('should re-add the note after undo using execute', () => {
|
||||
// Execute, undo, then execute again (redo pattern)
|
||||
command.execute()
|
||||
command.undo()
|
||||
command.execute();
|
||||
command.undo();
|
||||
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
||||
command.execute() // Commands are re-executed for redo
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||
command.execute(); // Commands are re-executed for redo
|
||||
|
||||
// Note should be added again
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
it('should return descriptive text', () => {
|
||||
const description = command.getDescription()
|
||||
const description = command.getDescription();
|
||||
|
||||
expect(description).toBeDefined()
|
||||
expect(typeof description).toBe('string')
|
||||
expect(description.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
expect(description).toBeDefined();
|
||||
expect(typeof description).toBe('string');
|
||||
expect(description.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command lifecycle', () => {
|
||||
it('should support multiple execute/undo cycles', () => {
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote')
|
||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote')
|
||||
const addNoteSpy = vi.spyOn(mockRegion, 'addNote');
|
||||
const removeNoteSpy = vi.spyOn(mockRegion, 'removeNote');
|
||||
|
||||
// Execute -> Undo -> Execute -> Undo
|
||||
command.execute()
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1)
|
||||
command.execute();
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
command.undo()
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1)
|
||||
command.undo();
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
command.execute() // Re-execute for redo
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(2)
|
||||
command.execute(); // Re-execute for redo
|
||||
expect(addNoteSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
command.undo()
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
command.undo();
|
||||
expect(removeNoteSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,9 +47,8 @@ class MockFileHandle {
|
||||
async getFile() { return { text: () => Promise.resolve(this._content) }; }
|
||||
async createWritable() {
|
||||
const w = new MockWritable();
|
||||
const self = this;
|
||||
const origClose = w.close.bind(w);
|
||||
w.close = async () => { self._content = w.data; await origClose(); };
|
||||
w.close = async () => { this._content = w.data; await origClose(); };
|
||||
return w;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ interface AppConfig {
|
||||
enable_audio_capture_for_screen_sharing: boolean;
|
||||
lookahead_time: number;
|
||||
playback_delay: number;
|
||||
recording_offset: number;
|
||||
};
|
||||
templates: {
|
||||
custom_instructions: string;
|
||||
@@ -250,7 +251,8 @@ export class ConfigManager {
|
||||
audio: {
|
||||
enable_audio_capture_for_screen_sharing: false,
|
||||
lookahead_time: 0.05,
|
||||
playback_delay: 0.2
|
||||
playback_delay: 0.2,
|
||||
recording_offset: 0
|
||||
},
|
||||
templates: {
|
||||
custom_instructions: ''
|
||||
|
||||
@@ -19,10 +19,9 @@ class MockFileSystemFileHandle {
|
||||
async createWritable() {
|
||||
const stream = new MockFileSystemWritableFileStream();
|
||||
// When stream closes, update our content
|
||||
const self = this;
|
||||
const origClose = stream.close.bind(stream);
|
||||
stream.close = async () => {
|
||||
self._content = stream.data;
|
||||
this._content = stream.data;
|
||||
await origClose();
|
||||
};
|
||||
return stream;
|
||||
|
||||
@@ -15,6 +15,10 @@ export class KGMidiInput {
|
||||
private isInitialized: boolean = false;
|
||||
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() {
|
||||
console.log("KGMidiInput initialized");
|
||||
@@ -161,11 +165,13 @@ export class KGMidiInput {
|
||||
if (command === 0x90 && velocity > 0) {
|
||||
console.log(`MIDI Note On: pitch=${pitch}, velocity=${velocity}, channel=${channel}`);
|
||||
this.triggerNoteOn(pitch, velocity);
|
||||
this.onRecordNoteOn?.(pitch);
|
||||
}
|
||||
// Note Off: command = 0x80 (128) or Note On with velocity 0
|
||||
else if (command === 0x80 || (command === 0x90 && velocity === 0)) {
|
||||
console.log(`MIDI Note Off: pitch=${pitch}, channel=${channel}`);
|
||||
this.triggerNoteOff(pitch);
|
||||
this.onRecordNoteOff?.(pitch);
|
||||
}
|
||||
// Control Change: command = 0xB0 (176)
|
||||
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 =====
|
||||
|
||||
public getIsInitialized(): boolean {
|
||||
|
||||
@@ -1,151 +1,151 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { KGMidiNote } from './KGMidiNote'
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { KGMidiNote } from './KGMidiNote';
|
||||
|
||||
describe('KGMidiNote', () => {
|
||||
let note: KGMidiNote
|
||||
let note: KGMidiNote;
|
||||
|
||||
beforeEach(() => {
|
||||
note = new KGMidiNote('test-note', 0, 1, 60, 80)
|
||||
})
|
||||
note = new KGMidiNote('test-note', 0, 1, 60, 80);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a note with correct properties', () => {
|
||||
const testNote = new KGMidiNote('note-1', 2, 4, 72, 100)
|
||||
const testNote = new KGMidiNote('note-1', 2, 4, 72, 100);
|
||||
|
||||
expect(testNote.getId()).toBe('note-1')
|
||||
expect(testNote.getStartBeat()).toBe(2)
|
||||
expect(testNote.getEndBeat()).toBe(4)
|
||||
expect(testNote.getPitch()).toBe(72)
|
||||
expect(testNote.getVelocity()).toBe(100)
|
||||
})
|
||||
expect(testNote.getId()).toBe('note-1');
|
||||
expect(testNote.getStartBeat()).toBe(2);
|
||||
expect(testNote.getEndBeat()).toBe(4);
|
||||
expect(testNote.getPitch()).toBe(72);
|
||||
expect(testNote.getVelocity()).toBe(100);
|
||||
});
|
||||
|
||||
it('should use default values when not provided', () => {
|
||||
const defaultNote = new KGMidiNote('default-note')
|
||||
const defaultNote = new KGMidiNote('default-note');
|
||||
|
||||
expect(defaultNote.getId()).toBe('default-note')
|
||||
expect(defaultNote.getStartBeat()).toBe(0)
|
||||
expect(defaultNote.getEndBeat()).toBe(0)
|
||||
expect(defaultNote.getPitch()).toBe(0)
|
||||
expect(defaultNote.getVelocity()).toBe(127)
|
||||
})
|
||||
})
|
||||
expect(defaultNote.getId()).toBe('default-note');
|
||||
expect(defaultNote.getStartBeat()).toBe(0);
|
||||
expect(defaultNote.getEndBeat()).toBe(0);
|
||||
expect(defaultNote.getPitch()).toBe(0);
|
||||
expect(defaultNote.getVelocity()).toBe(127);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters and setters', () => {
|
||||
it('should get and set start beat', () => {
|
||||
expect(note.getStartBeat()).toBe(0)
|
||||
expect(note.getStartBeat()).toBe(0);
|
||||
|
||||
note.setStartBeat(1.5)
|
||||
expect(note.getStartBeat()).toBe(1.5)
|
||||
})
|
||||
note.setStartBeat(1.5);
|
||||
expect(note.getStartBeat()).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should get and set end beat', () => {
|
||||
expect(note.getEndBeat()).toBe(1)
|
||||
expect(note.getEndBeat()).toBe(1);
|
||||
|
||||
note.setEndBeat(3.5)
|
||||
expect(note.getEndBeat()).toBe(3.5)
|
||||
})
|
||||
note.setEndBeat(3.5);
|
||||
expect(note.getEndBeat()).toBe(3.5);
|
||||
});
|
||||
|
||||
it('should get and set pitch', () => {
|
||||
expect(note.getPitch()).toBe(60)
|
||||
expect(note.getPitch()).toBe(60);
|
||||
|
||||
note.setPitch(72)
|
||||
expect(note.getPitch()).toBe(72)
|
||||
})
|
||||
note.setPitch(72);
|
||||
expect(note.getPitch()).toBe(72);
|
||||
});
|
||||
|
||||
it('should get and set velocity', () => {
|
||||
expect(note.getVelocity()).toBe(80)
|
||||
expect(note.getVelocity()).toBe(80);
|
||||
|
||||
note.setVelocity(100)
|
||||
expect(note.getVelocity()).toBe(100)
|
||||
})
|
||||
note.setVelocity(100);
|
||||
expect(note.getVelocity()).toBe(100);
|
||||
});
|
||||
|
||||
it('should get and set ID', () => {
|
||||
expect(note.getId()).toBe('test-note')
|
||||
expect(note.getId()).toBe('test-note');
|
||||
|
||||
note.setId('new-id')
|
||||
expect(note.getId()).toBe('new-id')
|
||||
})
|
||||
})
|
||||
note.setId('new-id');
|
||||
expect(note.getId()).toBe('new-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection', () => {
|
||||
it('should start unselected', () => {
|
||||
expect(note.isSelected()).toBe(false)
|
||||
})
|
||||
expect(note.isSelected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should select and deselect', () => {
|
||||
note.select()
|
||||
expect(note.isSelected()).toBe(true)
|
||||
note.select();
|
||||
expect(note.isSelected()).toBe(true);
|
||||
|
||||
note.deselect()
|
||||
expect(note.isSelected()).toBe(false)
|
||||
})
|
||||
})
|
||||
note.deselect();
|
||||
expect(note.isSelected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('note duration', () => {
|
||||
it('should calculate duration correctly', () => {
|
||||
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80)
|
||||
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2)
|
||||
})
|
||||
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80);
|
||||
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle zero duration', () => {
|
||||
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80)
|
||||
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0)
|
||||
})
|
||||
})
|
||||
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80);
|
||||
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitch validation', () => {
|
||||
it('should accept valid MIDI pitch range', () => {
|
||||
// MIDI pitch range is typically 0-127
|
||||
note.setPitch(0)
|
||||
expect(note.getPitch()).toBe(0)
|
||||
note.setPitch(0);
|
||||
expect(note.getPitch()).toBe(0);
|
||||
|
||||
note.setPitch(127)
|
||||
expect(note.getPitch()).toBe(127)
|
||||
note.setPitch(127);
|
||||
expect(note.getPitch()).toBe(127);
|
||||
|
||||
note.setPitch(60) // Middle C
|
||||
expect(note.getPitch()).toBe(60)
|
||||
})
|
||||
})
|
||||
note.setPitch(60); // Middle C
|
||||
expect(note.getPitch()).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('velocity validation', () => {
|
||||
it('should accept valid MIDI velocity range', () => {
|
||||
// MIDI velocity range is typically 0-127
|
||||
note.setVelocity(0)
|
||||
expect(note.getVelocity()).toBe(0)
|
||||
note.setVelocity(0);
|
||||
expect(note.getVelocity()).toBe(0);
|
||||
|
||||
note.setVelocity(127)
|
||||
expect(note.getVelocity()).toBe(127)
|
||||
note.setVelocity(127);
|
||||
expect(note.getVelocity()).toBe(127);
|
||||
|
||||
note.setVelocity(64) // Mid velocity
|
||||
expect(note.getVelocity()).toBe(64)
|
||||
})
|
||||
})
|
||||
note.setVelocity(64); // Mid velocity
|
||||
expect(note.getVelocity()).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clone and comparison', () => {
|
||||
it('should create independent instances', () => {
|
||||
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80)
|
||||
const note2 = new KGMidiNote('note-2', 0, 1, 60, 80)
|
||||
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80);
|
||||
const note2 = new KGMidiNote('note-2', 0, 1, 60, 80);
|
||||
|
||||
expect(note1.getId()).not.toBe(note2.getId())
|
||||
expect(note1.getId()).not.toBe(note2.getId());
|
||||
|
||||
note1.setPitch(72)
|
||||
expect(note2.getPitch()).toBe(60) // Should remain unchanged
|
||||
})
|
||||
})
|
||||
note1.setPitch(72);
|
||||
expect(note2.getPitch()).toBe(60); // Should remain unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative start beat', () => {
|
||||
note.setStartBeat(-1)
|
||||
expect(note.getStartBeat()).toBe(-1)
|
||||
})
|
||||
note.setStartBeat(-1);
|
||||
expect(note.getStartBeat()).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle start beat after end beat', () => {
|
||||
note.setStartBeat(5)
|
||||
note.setEndBeat(2)
|
||||
note.setStartBeat(5);
|
||||
note.setEndBeat(2);
|
||||
|
||||
expect(note.getStartBeat()).toBe(5)
|
||||
expect(note.getEndBeat()).toBe(2)
|
||||
expect(note.getStartBeat()).toBe(5);
|
||||
expect(note.getEndBeat()).toBe(2);
|
||||
// Note: The class might need validation logic to prevent this
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { upgradeToV3 } from './upgradeToV3';
|
||||
import { upgradeToV4 } from './upgradeToV4';
|
||||
import { upgradeToV5 } from './upgradeToV5';
|
||||
import { upgradeToV6 } from './upgradeToV6';
|
||||
import { upgradeToV7 } from './upgradeToV7';
|
||||
|
||||
/**
|
||||
* Upgrade the given project to the latest structure version, one version at a time.
|
||||
@@ -48,6 +49,10 @@ export function upgradeProjectToLatest(project: KGProject): KGProject {
|
||||
workingProject = upgradeToV6(workingProject);
|
||||
break;
|
||||
}
|
||||
case 7: {
|
||||
workingProject = upgradeToV7(workingProject);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// If an upgrader is missing, throw to prevent loading incompatible structures
|
||||
throw new Error(`No upgrader found for project structure version ${nextVersion}`);
|
||||
|
||||
@@ -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 { KGMidiRegion } from './KGMidiRegion'
|
||||
import { KGRegion } from './KGRegion'
|
||||
import { KGMidiNote } from '../midi/KGMidiNote'
|
||||
import { createMockMidiNote } from '../../test/utils/mock-data'
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { KGMidiRegion } from './KGMidiRegion';
|
||||
import { KGRegion } from './KGRegion';
|
||||
import { KGMidiNote } from '../midi/KGMidiNote';
|
||||
import { createMockMidiNote } from '../../test/utils/mock-data';
|
||||
|
||||
describe('KGMidiRegion', () => {
|
||||
let region: KGMidiRegion
|
||||
let region: KGMidiRegion;
|
||||
|
||||
beforeEach(() => {
|
||||
region = new KGMidiRegion(
|
||||
@@ -15,8 +15,8 @@ describe('KGMidiRegion', () => {
|
||||
'Test Region',
|
||||
0,
|
||||
4
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a MIDI region with correct properties', () => {
|
||||
@@ -27,327 +27,327 @@ describe('KGMidiRegion', () => {
|
||||
'My Region',
|
||||
4,
|
||||
8
|
||||
)
|
||||
);
|
||||
|
||||
expect(testRegion.getId()).toBe('region-1')
|
||||
expect(testRegion.getTrackId()).toBe('track-1')
|
||||
expect(testRegion.getTrackIndex()).toBe(2)
|
||||
expect(testRegion.getName()).toBe('My Region')
|
||||
expect(testRegion.getStartFromBeat()).toBe(4)
|
||||
expect(testRegion.getLength()).toBe(8)
|
||||
expect(testRegion.getNotes()).toEqual([])
|
||||
})
|
||||
expect(testRegion.getId()).toBe('region-1');
|
||||
expect(testRegion.getTrackId()).toBe('track-1');
|
||||
expect(testRegion.getTrackIndex()).toBe(2);
|
||||
expect(testRegion.getName()).toBe('My Region');
|
||||
expect(testRegion.getStartFromBeat()).toBe(4);
|
||||
expect(testRegion.getLength()).toBe(8);
|
||||
expect(testRegion.getNotes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use default values for optional parameters', () => {
|
||||
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region')
|
||||
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region');
|
||||
|
||||
expect(defaultRegion.getStartFromBeat()).toBe(0)
|
||||
expect(defaultRegion.getLength()).toBe(0)
|
||||
expect(defaultRegion.getNotes()).toEqual([])
|
||||
})
|
||||
expect(defaultRegion.getStartFromBeat()).toBe(0);
|
||||
expect(defaultRegion.getLength()).toBe(0);
|
||||
expect(defaultRegion.getNotes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set the correct type identifier', () => {
|
||||
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
||||
})
|
||||
})
|
||||
expect(region.getCurrentType()).toBe('KGMidiRegion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('note management', () => {
|
||||
let note1: KGMidiNote
|
||||
let note2: KGMidiNote
|
||||
let note3: KGMidiNote
|
||||
let note1: KGMidiNote;
|
||||
let note2: KGMidiNote;
|
||||
let note3: KGMidiNote;
|
||||
|
||||
beforeEach(() => {
|
||||
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 })
|
||||
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 })
|
||||
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 })
|
||||
})
|
||||
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 });
|
||||
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 });
|
||||
});
|
||||
|
||||
describe('addNote', () => {
|
||||
it('should add a single note to empty region', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note1);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(1)
|
||||
expect(notes[0]).toBe(note1)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toBe(note1);
|
||||
});
|
||||
|
||||
it('should add multiple notes to region', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note3)
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
region.addNote(note3);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(3)
|
||||
expect(notes).toContain(note1)
|
||||
expect(notes).toContain(note2)
|
||||
expect(notes).toContain(note3)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(3);
|
||||
expect(notes).toContain(note1);
|
||||
expect(notes).toContain(note2);
|
||||
expect(notes).toContain(note3);
|
||||
});
|
||||
|
||||
it('should maintain note order when adding', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note3)
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
region.addNote(note3);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes[0]).toBe(note1)
|
||||
expect(notes[1]).toBe(note2)
|
||||
expect(notes[2]).toBe(note3)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes[0]).toBe(note1);
|
||||
expect(notes[1]).toBe(note2);
|
||||
expect(notes[2]).toBe(note3);
|
||||
});
|
||||
|
||||
it('should allow adding the same note multiple times', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note1)
|
||||
region.addNote(note1);
|
||||
region.addNote(note1);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(notes[0]).toBe(note1)
|
||||
expect(notes[1]).toBe(note1)
|
||||
})
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes[0]).toBe(note1);
|
||||
expect(notes[1]).toBe(note1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNote', () => {
|
||||
beforeEach(() => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note3)
|
||||
})
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
region.addNote(note3);
|
||||
});
|
||||
|
||||
it('should remove note by ID', () => {
|
||||
region.removeNote('note-2')
|
||||
region.removeNote('note-2');
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(notes).toContain(note1)
|
||||
expect(notes).toContain(note3)
|
||||
expect(notes).not.toContain(note2)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes).toContain(note1);
|
||||
expect(notes).toContain(note3);
|
||||
expect(notes).not.toContain(note2);
|
||||
});
|
||||
|
||||
it('should handle removing non-existent note gracefully', () => {
|
||||
const initialLength = region.getNotes().length
|
||||
const initialLength = region.getNotes().length;
|
||||
|
||||
region.removeNote('non-existent-note')
|
||||
region.removeNote('non-existent-note');
|
||||
|
||||
expect(region.getNotes()).toHaveLength(initialLength)
|
||||
})
|
||||
expect(region.getNotes()).toHaveLength(initialLength);
|
||||
});
|
||||
|
||||
it('should remove all instances when note ID appears multiple times', () => {
|
||||
// Add another note with same ID as note1
|
||||
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 })
|
||||
region.addNote(duplicateNote)
|
||||
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 });
|
||||
region.addNote(duplicateNote);
|
||||
|
||||
expect(region.getNotes()).toHaveLength(4)
|
||||
expect(region.getNotes()).toHaveLength(4);
|
||||
|
||||
region.removeNote('note-1')
|
||||
region.removeNote('note-1');
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(notes).toContain(note2)
|
||||
expect(notes).toContain(note3)
|
||||
expect(notes).not.toContain(note1)
|
||||
expect(notes).not.toContain(duplicateNote)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes).toContain(note2);
|
||||
expect(notes).toContain(note3);
|
||||
expect(notes).not.toContain(note1);
|
||||
expect(notes).not.toContain(duplicateNote);
|
||||
});
|
||||
|
||||
it('should handle removing from empty region', () => {
|
||||
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region')
|
||||
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region');
|
||||
|
||||
expect(() => emptyRegion.removeNote('note-1')).not.toThrow()
|
||||
expect(emptyRegion.getNotes()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
expect(() => emptyRegion.removeNote('note-1')).not.toThrow();
|
||||
expect(emptyRegion.getNotes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotes', () => {
|
||||
it('should return empty array for new region', () => {
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toEqual([])
|
||||
expect(notes).toHaveLength(0)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toEqual([]);
|
||||
expect(notes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return all notes in region', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(notes).toEqual([note1, note2])
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes).toEqual([note1, note2]);
|
||||
});
|
||||
|
||||
it('should return a reference to the internal notes array', () => {
|
||||
region.addNote(note1)
|
||||
const notes1 = region.getNotes()
|
||||
const notes2 = region.getNotes()
|
||||
region.addNote(note1);
|
||||
const notes1 = region.getNotes();
|
||||
const notes2 = region.getNotes();
|
||||
|
||||
expect(notes1).toBe(notes2) // Same reference
|
||||
})
|
||||
})
|
||||
expect(notes1).toBe(notes2); // Same reference
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNotes', () => {
|
||||
it('should replace all notes with new array', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
|
||||
expect(region.getNotes()).toHaveLength(2)
|
||||
expect(region.getNotes()).toHaveLength(2);
|
||||
|
||||
region.setNotes([note3])
|
||||
region.setNotes([note3]);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(1)
|
||||
expect(notes[0]).toBe(note3)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toBe(note3);
|
||||
});
|
||||
|
||||
it('should allow setting empty notes array', () => {
|
||||
region.addNote(note1)
|
||||
region.addNote(note2)
|
||||
region.addNote(note1);
|
||||
region.addNote(note2);
|
||||
|
||||
region.setNotes([])
|
||||
region.setNotes([]);
|
||||
|
||||
expect(region.getNotes()).toHaveLength(0)
|
||||
})
|
||||
expect(region.getNotes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept notes array with multiple notes', () => {
|
||||
const newNotes = [note1, note2, note3]
|
||||
region.setNotes(newNotes)
|
||||
const newNotes = [note1, note2, note3];
|
||||
region.setNotes(newNotes);
|
||||
|
||||
const retrievedNotes = region.getNotes()
|
||||
expect(retrievedNotes).toHaveLength(3)
|
||||
expect(retrievedNotes).toEqual(newNotes)
|
||||
})
|
||||
})
|
||||
})
|
||||
const retrievedNotes = region.getNotes();
|
||||
expect(retrievedNotes).toHaveLength(3);
|
||||
expect(retrievedNotes).toEqual(newNotes);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance from KGRegion', () => {
|
||||
it('should inherit all base region properties', () => {
|
||||
expect(region.getId()).toBe('test-region-1')
|
||||
expect(region.getTrackId()).toBe('test-track-1')
|
||||
expect(region.getTrackIndex()).toBe(0)
|
||||
expect(region.getName()).toBe('Test Region')
|
||||
expect(region.getStartFromBeat()).toBe(0)
|
||||
expect(region.getLength()).toBe(4)
|
||||
})
|
||||
expect(region.getId()).toBe('test-region-1');
|
||||
expect(region.getTrackId()).toBe('test-track-1');
|
||||
expect(region.getTrackIndex()).toBe(0);
|
||||
expect(region.getName()).toBe('Test Region');
|
||||
expect(region.getStartFromBeat()).toBe(0);
|
||||
expect(region.getLength()).toBe(4);
|
||||
});
|
||||
|
||||
it('should inherit selection functionality', () => {
|
||||
expect(region.isSelected()).toBe(false)
|
||||
expect(region.isSelected()).toBe(false);
|
||||
|
||||
region.select()
|
||||
expect(region.isSelected()).toBe(true)
|
||||
region.select();
|
||||
expect(region.isSelected()).toBe(true);
|
||||
|
||||
region.deselect()
|
||||
expect(region.isSelected()).toBe(false)
|
||||
})
|
||||
region.deselect();
|
||||
expect(region.isSelected()).toBe(false);
|
||||
});
|
||||
|
||||
it('should inherit setters from base class', () => {
|
||||
region.setName('Updated Region')
|
||||
expect(region.getName()).toBe('Updated Region')
|
||||
region.setName('Updated Region');
|
||||
expect(region.getName()).toBe('Updated Region');
|
||||
|
||||
region.setStartFromBeat(8)
|
||||
expect(region.getStartFromBeat()).toBe(8)
|
||||
region.setStartFromBeat(8);
|
||||
expect(region.getStartFromBeat()).toBe(8);
|
||||
|
||||
region.setLength(12)
|
||||
expect(region.getLength()).toBe(12)
|
||||
})
|
||||
})
|
||||
region.setLength(12);
|
||||
expect(region.getLength()).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type identification', () => {
|
||||
it('should return correct current type', () => {
|
||||
expect(region.getCurrentType()).toBe('KGMidiRegion')
|
||||
})
|
||||
expect(region.getCurrentType()).toBe('KGMidiRegion');
|
||||
});
|
||||
|
||||
it('should return correct root type', () => {
|
||||
expect(region.getRootType()).toBe('KGRegion')
|
||||
})
|
||||
expect(region.getRootType()).toBe('KGRegion');
|
||||
});
|
||||
|
||||
it('should be instanceof both KGMidiRegion and KGRegion', () => {
|
||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||
expect(region).toBeInstanceOf(KGRegion)
|
||||
})
|
||||
})
|
||||
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||
expect(region).toBeInstanceOf(KGRegion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle notes with overlapping time ranges', () => {
|
||||
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 })
|
||||
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 })
|
||||
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 });
|
||||
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 });
|
||||
|
||||
region.addNote(overlappingNote1)
|
||||
region.addNote(overlappingNote2)
|
||||
region.addNote(overlappingNote1);
|
||||
region.addNote(overlappingNote2);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(2)
|
||||
expect(notes).toContain(overlappingNote1)
|
||||
expect(notes).toContain(overlappingNote2)
|
||||
})
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes).toContain(overlappingNote1);
|
||||
expect(notes).toContain(overlappingNote2);
|
||||
});
|
||||
|
||||
it('should handle notes with same pitch but different timing', () => {
|
||||
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 })
|
||||
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 })
|
||||
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 });
|
||||
|
||||
region.addNote(sameNote1)
|
||||
region.addNote(sameNote2)
|
||||
region.addNote(sameNote1);
|
||||
region.addNote(sameNote2);
|
||||
|
||||
expect(region.getNotes()).toHaveLength(2)
|
||||
})
|
||||
expect(region.getNotes()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle notes outside region boundaries', () => {
|
||||
// Region is from beat 0 to 4, but note extends beyond
|
||||
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 })
|
||||
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 });
|
||||
|
||||
region.addNote(outsideNote)
|
||||
region.addNote(outsideNote);
|
||||
|
||||
const notes = region.getNotes()
|
||||
expect(notes).toHaveLength(1)
|
||||
expect(notes[0]).toBe(outsideNote)
|
||||
const notes = region.getNotes();
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]).toBe(outsideNote);
|
||||
// Note: The region doesn't enforce boundary constraints - that's application logic
|
||||
})
|
||||
});
|
||||
|
||||
it('should handle zero-length region', () => {
|
||||
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0)
|
||||
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 })
|
||||
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0);
|
||||
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||
|
||||
zeroRegion.addNote(note)
|
||||
zeroRegion.addNote(note);
|
||||
|
||||
expect(zeroRegion.getNotes()).toHaveLength(1)
|
||||
expect(zeroRegion.getLength()).toBe(0)
|
||||
})
|
||||
})
|
||||
expect(zeroRegion.getNotes()).toHaveLength(1);
|
||||
expect(zeroRegion.getLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data consistency', () => {
|
||||
it('should maintain note references correctly', () => {
|
||||
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 })
|
||||
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 });
|
||||
|
||||
region.addNote(originalNote)
|
||||
const retrievedNote = region.getNotes()[0]
|
||||
region.addNote(originalNote);
|
||||
const retrievedNote = region.getNotes()[0];
|
||||
|
||||
expect(retrievedNote).toBe(originalNote) // Same reference
|
||||
expect(retrievedNote).toBe(originalNote); // Same reference
|
||||
|
||||
// Modify original note
|
||||
originalNote.setPitch(64)
|
||||
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
|
||||
})
|
||||
originalNote.setPitch(64);
|
||||
expect(retrievedNote.getPitch()).toBe(64); // Should reflect change
|
||||
});
|
||||
|
||||
it('should handle concurrent modifications correctly', () => {
|
||||
const notes = [
|
||||
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
|
||||
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
|
||||
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
|
||||
]
|
||||
];
|
||||
|
||||
// Add notes
|
||||
notes.forEach(note => region.addNote(note))
|
||||
expect(region.getNotes()).toHaveLength(3)
|
||||
notes.forEach(note => region.addNote(note));
|
||||
expect(region.getNotes()).toHaveLength(3);
|
||||
|
||||
// Remove middle note
|
||||
region.removeNote('concurrent-2')
|
||||
expect(region.getNotes()).toHaveLength(2)
|
||||
region.removeNote('concurrent-2');
|
||||
expect(region.getNotes()).toHaveLength(2);
|
||||
|
||||
// Add new note
|
||||
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 })
|
||||
region.addNote(newNote)
|
||||
expect(region.getNotes()).toHaveLength(3)
|
||||
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 });
|
||||
region.addNote(newNote);
|
||||
expect(region.getNotes()).toHaveLength(3);
|
||||
|
||||
// Verify final state
|
||||
const finalNotes = region.getNotes()
|
||||
expect(finalNotes).toContain(notes[0]) // concurrent-1
|
||||
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed)
|
||||
expect(finalNotes).toContain(notes[2]) // concurrent-3
|
||||
expect(finalNotes).toContain(newNote) // concurrent-4
|
||||
})
|
||||
})
|
||||
})
|
||||
const finalNotes = region.getNotes();
|
||||
expect(finalNotes).toContain(notes[0]); // concurrent-1
|
||||
expect(finalNotes).not.toContain(notes[1]); // concurrent-2 (removed)
|
||||
expect(finalNotes).toContain(notes[2]); // concurrent-3
|
||||
expect(finalNotes).toContain(newNote); // concurrent-4
|
||||
});
|
||||
});
|
||||
});
|
||||
+208
-202
@@ -1,70 +1,70 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'
|
||||
import { KGTrack, TrackType } from './KGTrack'
|
||||
import { KGMidiRegion } from '../region/KGMidiRegion'
|
||||
import { createMockMidiRegion } from '../../test/utils/mock-data'
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack';
|
||||
import { KGTrack, TrackType } from './KGTrack';
|
||||
import { KGMidiRegion } from '../region/KGMidiRegion';
|
||||
import { createMockMidiRegion } from '../../test/utils/mock-data';
|
||||
|
||||
describe('KGMidiTrack', () => {
|
||||
let track: KGMidiTrack
|
||||
let track: KGMidiTrack;
|
||||
|
||||
beforeEach(() => {
|
||||
track = new KGMidiTrack()
|
||||
})
|
||||
track = new KGMidiTrack();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create track with default values', () => {
|
||||
const defaultTrack = new KGMidiTrack()
|
||||
const defaultTrack = new KGMidiTrack();
|
||||
|
||||
expect(defaultTrack.getName()).toBe('Untitled MIDI Track')
|
||||
expect(defaultTrack.getId()).toBe(0)
|
||||
expect(defaultTrack.getType()).toBe(TrackType.MIDI)
|
||||
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano')
|
||||
expect(defaultTrack.getVolume()).toBe(0.8) // DEFAULT_TRACK_VOLUME
|
||||
expect(defaultTrack.getRegions()).toEqual([])
|
||||
})
|
||||
expect(defaultTrack.getName()).toBe('Untitled MIDI Track');
|
||||
expect(defaultTrack.getId()).toBe(0);
|
||||
expect(defaultTrack.getType()).toBe(TrackType.MIDI);
|
||||
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano');
|
||||
expect(defaultTrack.getVolume()).toBe(0); // DEFAULT_TRACK_VOLUME (0 dB)
|
||||
expect(defaultTrack.getRegions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should create track with custom parameters', () => {
|
||||
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', 0.6)
|
||||
const customTrack = new KGMidiTrack('My Piano Track', 5, 'electric_piano_1', -4);
|
||||
|
||||
expect(customTrack.getName()).toBe('My Piano Track')
|
||||
expect(customTrack.getId()).toBe(5)
|
||||
expect(customTrack.getType()).toBe(TrackType.MIDI)
|
||||
expect(customTrack.getInstrument()).toBe('electric_piano_1')
|
||||
expect(customTrack.getVolume()).toBe(0.6)
|
||||
})
|
||||
expect(customTrack.getName()).toBe('My Piano Track');
|
||||
expect(customTrack.getId()).toBe(5);
|
||||
expect(customTrack.getType()).toBe(TrackType.MIDI);
|
||||
expect(customTrack.getInstrument()).toBe('electric_piano_1');
|
||||
expect(customTrack.getVolume()).toBe(-4);
|
||||
});
|
||||
|
||||
it('should set correct type identifier', () => {
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
||||
})
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack');
|
||||
});
|
||||
|
||||
it('should inherit from KGTrack', () => {
|
||||
expect(track).toBeInstanceOf(KGMidiTrack)
|
||||
expect(track).toBeInstanceOf(KGTrack)
|
||||
})
|
||||
})
|
||||
expect(track).toBeInstanceOf(KGMidiTrack);
|
||||
expect(track).toBeInstanceOf(KGTrack);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrument management', () => {
|
||||
describe('getInstrument', () => {
|
||||
it('should return default instrument when not set', () => {
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano')
|
||||
})
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano');
|
||||
});
|
||||
|
||||
it('should return current instrument', () => {
|
||||
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean')
|
||||
expect(customTrack.getInstrument()).toBe('electric_guitar_clean')
|
||||
})
|
||||
const customTrack = new KGMidiTrack('Test', 0, 'electric_guitar_clean');
|
||||
expect(customTrack.getInstrument()).toBe('electric_guitar_clean');
|
||||
});
|
||||
|
||||
it('should provide backward compatibility for undefined instrument', () => {
|
||||
// This tests the backward compatibility mentioned in the code
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano')
|
||||
})
|
||||
})
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setInstrument', () => {
|
||||
it('should update instrument', () => {
|
||||
track.setInstrument('violin')
|
||||
expect(track.getInstrument()).toBe('violin')
|
||||
})
|
||||
track.setInstrument('violin');
|
||||
expect(track.getInstrument()).toBe('violin');
|
||||
});
|
||||
|
||||
it('should handle different instrument types', () => {
|
||||
const instruments: InstrumentType[] = [
|
||||
@@ -75,28 +75,28 @@ describe('KGMidiTrack', () => {
|
||||
'violin',
|
||||
'trumpet',
|
||||
'flute'
|
||||
]
|
||||
];
|
||||
|
||||
instruments.forEach(instrument => {
|
||||
track.setInstrument(instrument)
|
||||
expect(track.getInstrument()).toBe(instrument)
|
||||
})
|
||||
})
|
||||
track.setInstrument(instrument);
|
||||
expect(track.getInstrument()).toBe(instrument);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rapid instrument changes', () => {
|
||||
track.setInstrument('piano')
|
||||
track.setInstrument('guitar')
|
||||
track.setInstrument('violin')
|
||||
track.setInstrument('piano');
|
||||
track.setInstrument('guitar');
|
||||
track.setInstrument('violin');
|
||||
|
||||
expect(track.getInstrument()).toBe('violin')
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(track.getInstrument()).toBe('violin');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('region management', () => {
|
||||
let region1: KGMidiRegion
|
||||
let region2: KGMidiRegion
|
||||
let region3: KGMidiRegion
|
||||
let region1: KGMidiRegion;
|
||||
let region2: KGMidiRegion;
|
||||
let region3: KGMidiRegion;
|
||||
|
||||
beforeEach(() => {
|
||||
region1 = createMockMidiRegion({
|
||||
@@ -105,137 +105,137 @@ describe('KGMidiTrack', () => {
|
||||
name: 'Region 1',
|
||||
startFromBeat: 0,
|
||||
length: 4
|
||||
})
|
||||
});
|
||||
region2 = createMockMidiRegion({
|
||||
id: 'region-2',
|
||||
trackId: track.getId().toString(),
|
||||
name: 'Region 2',
|
||||
startFromBeat: 4,
|
||||
length: 4
|
||||
})
|
||||
});
|
||||
region3 = createMockMidiRegion({
|
||||
id: 'region-3',
|
||||
trackId: track.getId().toString(),
|
||||
name: 'Region 3',
|
||||
startFromBeat: 8,
|
||||
length: 4
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRegions', () => {
|
||||
it('should set regions array', () => {
|
||||
const regions = [region1, region2]
|
||||
track.setRegions(regions)
|
||||
const regions = [region1, region2];
|
||||
track.setRegions(regions);
|
||||
|
||||
expect(track.getRegions()).toHaveLength(2)
|
||||
expect(track.getRegions()).toEqual(regions)
|
||||
})
|
||||
expect(track.getRegions()).toHaveLength(2);
|
||||
expect(track.getRegions()).toEqual(regions);
|
||||
});
|
||||
|
||||
it('should replace existing regions', () => {
|
||||
track.setRegions([region1])
|
||||
expect(track.getRegions()).toHaveLength(1)
|
||||
track.setRegions([region1]);
|
||||
expect(track.getRegions()).toHaveLength(1);
|
||||
|
||||
track.setRegions([region2, region3])
|
||||
expect(track.getRegions()).toHaveLength(2)
|
||||
expect(track.getRegions()).toContain(region2)
|
||||
expect(track.getRegions()).toContain(region3)
|
||||
expect(track.getRegions()).not.toContain(region1)
|
||||
})
|
||||
track.setRegions([region2, region3]);
|
||||
expect(track.getRegions()).toHaveLength(2);
|
||||
expect(track.getRegions()).toContain(region2);
|
||||
expect(track.getRegions()).toContain(region3);
|
||||
expect(track.getRegions()).not.toContain(region1);
|
||||
});
|
||||
|
||||
it('should accept empty array', () => {
|
||||
track.setRegions([region1, region2])
|
||||
track.setRegions([])
|
||||
track.setRegions([region1, region2]);
|
||||
track.setRegions([]);
|
||||
|
||||
expect(track.getRegions()).toHaveLength(0)
|
||||
})
|
||||
expect(track.getRegions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should enforce KGMidiRegion type', () => {
|
||||
const regions: KGMidiRegion[] = [region1, region2]
|
||||
track.setRegions(regions)
|
||||
const regions: KGMidiRegion[] = [region1, region2];
|
||||
track.setRegions(regions);
|
||||
|
||||
const retrievedRegions = track.getRegions()
|
||||
const retrievedRegions = track.getRegions();
|
||||
retrievedRegions.forEach(region => {
|
||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('inherited region methods', () => {
|
||||
beforeEach(() => {
|
||||
track.setRegions([region1, region2])
|
||||
})
|
||||
track.setRegions([region1, region2]);
|
||||
});
|
||||
|
||||
it('should inherit addRegion method', () => {
|
||||
track.addRegion(region3)
|
||||
track.addRegion(region3);
|
||||
|
||||
const regions = track.getRegions()
|
||||
expect(regions).toHaveLength(3)
|
||||
expect(regions).toContain(region3)
|
||||
})
|
||||
const regions = track.getRegions();
|
||||
expect(regions).toHaveLength(3);
|
||||
expect(regions).toContain(region3);
|
||||
});
|
||||
|
||||
it('should inherit removeRegion method', () => {
|
||||
track.removeRegion('region-1')
|
||||
track.removeRegion('region-1');
|
||||
|
||||
const regions = track.getRegions()
|
||||
expect(regions).toHaveLength(1)
|
||||
expect(regions).not.toContain(region1)
|
||||
expect(regions).toContain(region2)
|
||||
})
|
||||
const regions = track.getRegions();
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions).not.toContain(region1);
|
||||
expect(regions).toContain(region2);
|
||||
});
|
||||
|
||||
it('should inherit getRegions method', () => {
|
||||
const regions = track.getRegions()
|
||||
expect(regions).toHaveLength(2)
|
||||
expect(regions).toContain(region1)
|
||||
expect(regions).toContain(region2)
|
||||
})
|
||||
})
|
||||
})
|
||||
const regions = track.getRegions();
|
||||
expect(regions).toHaveLength(2);
|
||||
expect(regions).toContain(region1);
|
||||
expect(regions).toContain(region2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance from KGTrack', () => {
|
||||
it('should inherit all base track properties', () => {
|
||||
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', 0.9)
|
||||
const customTrack = new KGMidiTrack('Test Track', 42, 'violin', -1);
|
||||
|
||||
expect(customTrack.getName()).toBe('Test Track')
|
||||
expect(customTrack.getId()).toBe(42)
|
||||
expect(customTrack.getType()).toBe(TrackType.MIDI)
|
||||
expect(customTrack.getVolume()).toBe(0.9)
|
||||
})
|
||||
expect(customTrack.getName()).toBe('Test Track');
|
||||
expect(customTrack.getId()).toBe(42);
|
||||
expect(customTrack.getType()).toBe(TrackType.MIDI);
|
||||
expect(customTrack.getVolume()).toBe(-1);
|
||||
});
|
||||
|
||||
it('should inherit base track setters', () => {
|
||||
track.setName('Updated Track')
|
||||
expect(track.getName()).toBe('Updated Track')
|
||||
track.setName('Updated Track');
|
||||
expect(track.getName()).toBe('Updated Track');
|
||||
|
||||
track.setVolume(0.5)
|
||||
expect(track.getVolume()).toBe(0.5)
|
||||
track.setVolume(-6);
|
||||
expect(track.getVolume()).toBe(-6);
|
||||
|
||||
track.setTrackIndex(3)
|
||||
expect(track.getTrackIndex()).toBe(3)
|
||||
})
|
||||
track.setTrackIndex(3);
|
||||
expect(track.getTrackIndex()).toBe(3);
|
||||
});
|
||||
|
||||
it('should inherit volume controls', () => {
|
||||
expect(track.getVolume()).toBe(0.8) // Default volume
|
||||
expect(track.getVolume()).toBe(0); // Default volume (0 dB)
|
||||
|
||||
track.setVolume(0.5)
|
||||
expect(track.getVolume()).toBe(0.5)
|
||||
track.setVolume(-6);
|
||||
expect(track.getVolume()).toBe(-6);
|
||||
|
||||
track.setVolume(1.0)
|
||||
expect(track.getVolume()).toBe(1.0)
|
||||
})
|
||||
})
|
||||
track.setVolume(0);
|
||||
expect(track.getVolume()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type identification', () => {
|
||||
it('should return correct current type', () => {
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
||||
})
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack');
|
||||
});
|
||||
|
||||
it('should return correct root type', () => {
|
||||
expect(track.getRootType()).toBe('KGTrack')
|
||||
})
|
||||
expect(track.getRootType()).toBe('KGTrack');
|
||||
});
|
||||
|
||||
it('should have MIDI track type', () => {
|
||||
expect(track.getType()).toBe(TrackType.MIDI)
|
||||
})
|
||||
})
|
||||
expect(track.getType()).toBe(TrackType.MIDI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrument type validation', () => {
|
||||
it('should handle all valid General MIDI instruments', () => {
|
||||
@@ -259,94 +259,100 @@ describe('KGMidiTrack', () => {
|
||||
'clarinet',
|
||||
'soprano_sax',
|
||||
'alto_sax'
|
||||
]
|
||||
];
|
||||
|
||||
validInstruments.forEach(instrument => {
|
||||
expect(() => {
|
||||
track.setInstrument(instrument)
|
||||
expect(track.getInstrument()).toBe(instrument)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
track.setInstrument(instrument);
|
||||
expect(track.getInstrument()).toBe(instrument);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('track state consistency', () => {
|
||||
it('should maintain consistent state after multiple operations', () => {
|
||||
// Setup initial state
|
||||
track.setName('Piano Track')
|
||||
track.setInstrument('acoustic_grand_piano')
|
||||
track.setVolume(0.7)
|
||||
|
||||
track.setName('Piano Track');
|
||||
track.setInstrument('acoustic_grand_piano');
|
||||
track.setVolume(-3);
|
||||
|
||||
const regions = [
|
||||
createMockMidiRegion({ id: 'r1', trackId: '0', name: 'Intro' }),
|
||||
createMockMidiRegion({ id: 'r2', trackId: '0', name: 'Verse' })
|
||||
]
|
||||
track.setRegions(regions)
|
||||
];
|
||||
track.setRegions(regions);
|
||||
|
||||
// Verify initial state
|
||||
expect(track.getName()).toBe('Piano Track')
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano')
|
||||
expect(track.getVolume()).toBe(0.7)
|
||||
expect(track.getRegions()).toHaveLength(2)
|
||||
expect(track.getName()).toBe('Piano Track');
|
||||
expect(track.getInstrument()).toBe('acoustic_grand_piano');
|
||||
expect(track.getVolume()).toBe(-3);
|
||||
expect(track.getRegions()).toHaveLength(2);
|
||||
|
||||
// Modify state
|
||||
track.setInstrument('electric_piano_1')
|
||||
track.addRegion(createMockMidiRegion({
|
||||
id: 'r3',
|
||||
trackId: '0',
|
||||
name: 'Chorus'
|
||||
}))
|
||||
track.setInstrument('electric_piano_1');
|
||||
track.addRegion(createMockMidiRegion({
|
||||
id: 'r3',
|
||||
trackId: '0',
|
||||
name: 'Chorus'
|
||||
}));
|
||||
|
||||
// Verify modified state
|
||||
expect(track.getName()).toBe('Piano Track')
|
||||
expect(track.getInstrument()).toBe('electric_piano_1')
|
||||
expect(track.getVolume()).toBe(0.7)
|
||||
expect(track.getRegions()).toHaveLength(3)
|
||||
})
|
||||
expect(track.getName()).toBe('Piano Track');
|
||||
expect(track.getInstrument()).toBe('electric_piano_1');
|
||||
expect(track.getVolume()).toBe(-3);
|
||||
expect(track.getRegions()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle region-track relationship correctly', () => {
|
||||
const region = createMockMidiRegion({
|
||||
id: 'test-region',
|
||||
trackId: track.getId().toString(),
|
||||
name: 'Test Region'
|
||||
})
|
||||
});
|
||||
|
||||
track.addRegion(region)
|
||||
track.addRegion(region);
|
||||
|
||||
// Verify region is in track
|
||||
expect(track.getRegions()).toContain(region)
|
||||
expect(track.getRegions()).toContain(region);
|
||||
|
||||
// Find region manually since getRegionById doesn't exist
|
||||
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region')
|
||||
expect(foundRegion).toBe(region)
|
||||
const foundRegion = track.getRegions().find(r => r.getId() === 'test-region');
|
||||
expect(foundRegion).toBe(region);
|
||||
|
||||
// Verify region properties
|
||||
expect(region.getTrackId()).toBe(track.getId().toString())
|
||||
})
|
||||
})
|
||||
expect(region.getTrackId()).toBe(track.getId().toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle empty track name', () => {
|
||||
const emptyNameTrack = new KGMidiTrack('', 0, 'piano')
|
||||
expect(emptyNameTrack.getName()).toBe('')
|
||||
})
|
||||
const emptyNameTrack = new KGMidiTrack('', 0, 'piano');
|
||||
expect(emptyNameTrack.getName()).toBe('');
|
||||
});
|
||||
|
||||
it('should handle negative track ID', () => {
|
||||
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano')
|
||||
expect(negativeIdTrack.getId()).toBe(-1)
|
||||
})
|
||||
const negativeIdTrack = new KGMidiTrack('Test', -1, 'piano');
|
||||
expect(negativeIdTrack.getId()).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle volume boundaries', () => {
|
||||
track.setVolume(0.0)
|
||||
expect(track.getVolume()).toBe(0.0)
|
||||
track.setVolume(-60);
|
||||
expect(track.getVolume()).toBe(-60);
|
||||
|
||||
track.setVolume(1.0)
|
||||
expect(track.getVolume()).toBe(1.0)
|
||||
track.setVolume(0);
|
||||
expect(track.getVolume()).toBe(0);
|
||||
|
||||
// Volume outside normal range (should still work)
|
||||
track.setVolume(1.5)
|
||||
expect(track.getVolume()).toBe(1.5)
|
||||
})
|
||||
track.setVolume(6);
|
||||
expect(track.getVolume()).toBe(6);
|
||||
|
||||
// Volume outside valid dB range is clamped
|
||||
track.setVolume(10);
|
||||
expect(track.getVolume()).toBe(6);
|
||||
|
||||
track.setVolume(-100);
|
||||
expect(track.getVolume()).toBe(-60);
|
||||
});
|
||||
|
||||
it('should handle large number of regions', () => {
|
||||
const manyRegions = Array.from({ length: 100 }, (_, i) =>
|
||||
@@ -355,44 +361,44 @@ describe('KGMidiTrack', () => {
|
||||
trackId: track.getId().toString(),
|
||||
name: `Region ${i}`
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
track.setRegions(manyRegions)
|
||||
expect(track.getRegions()).toHaveLength(100)
|
||||
track.setRegions(manyRegions);
|
||||
expect(track.getRegions()).toHaveLength(100);
|
||||
|
||||
// Should be able to find any region
|
||||
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50')
|
||||
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99')
|
||||
const foundRegion50 = track.getRegions().find(r => r.getId() === 'region-50');
|
||||
const foundRegion99 = track.getRegions().find(r => r.getId() === 'region-99');
|
||||
|
||||
expect(foundRegion50).toBeDefined()
|
||||
expect(foundRegion99).toBeDefined()
|
||||
})
|
||||
})
|
||||
expect(foundRegion50).toBeDefined();
|
||||
expect(foundRegion99).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class-transformer compatibility', () => {
|
||||
it('should have proper type annotations for serialization', () => {
|
||||
// Verify the class has the necessary decorators for serialization
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack')
|
||||
expect(track.getCurrentType()).toBe('KGMidiTrack');
|
||||
|
||||
// Test that default instrument fallback works
|
||||
const instrument = track.getInstrument()
|
||||
expect(instrument).toBe('acoustic_grand_piano')
|
||||
})
|
||||
const instrument = track.getInstrument();
|
||||
expect(instrument).toBe('acoustic_grand_piano');
|
||||
});
|
||||
|
||||
it('should maintain regions type after serialization simulation', () => {
|
||||
const regions = [
|
||||
createMockMidiRegion({ id: 'r1', trackId: '0' }),
|
||||
createMockMidiRegion({ id: 'r2', trackId: '0' })
|
||||
]
|
||||
];
|
||||
|
||||
track.setRegions(regions)
|
||||
track.setRegions(regions);
|
||||
|
||||
// Simulate what happens during serialization/deserialization
|
||||
const retrievedRegions = track.getRegions()
|
||||
expect(retrievedRegions).toHaveLength(2)
|
||||
const retrievedRegions = track.getRegions();
|
||||
expect(retrievedRegions).toHaveLength(2);
|
||||
retrievedRegions.forEach(region => {
|
||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,10 @@ export class KGTrack {
|
||||
}
|
||||
|
||||
public setVolume(volume: number): void {
|
||||
this.volume = volume;
|
||||
this.volume = Math.max(
|
||||
AUDIO_INTERFACE_CONSTANTS.MIN_TRACK_VOLUME_DB,
|
||||
Math.min(AUDIO_INTERFACE_CONSTANTS.MAX_TRACK_VOLUME_DB, volume)
|
||||
);
|
||||
}
|
||||
|
||||
public setRegions(regions: KGRegion[]): void {
|
||||
|
||||
@@ -5,13 +5,16 @@ import { saveProject } from '../util/saveUtil';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
import { useProjectStore } from '../stores/projectStore';
|
||||
import { selectAllNotesInActiveRegion } from '../util/selectionUtil';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { KGMidiInput } from '../core/midi-input/KGMidiInput';
|
||||
import { KGMidiRegion } from '../core/region/KGMidiRegion';
|
||||
|
||||
/**
|
||||
* Global keyboard handler for copy/paste, undo/redo, play/pause, and save operations
|
||||
* Handles keyboard shortcuts defined in the configuration
|
||||
*/
|
||||
export const useGlobalKeyboardHandler = () => {
|
||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName } = useProjectStore();
|
||||
const { undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, savedProjectName, setSavedProjectName, setProjectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll } = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -64,6 +67,7 @@ export const useGlobalKeyboardHandler = () => {
|
||||
const playShortcut = configManager.get('hotkeys.main.play') as string;
|
||||
const loopShortcut = configManager.get('hotkeys.main.loop') as string;
|
||||
const saveShortcut = configManager.get('hotkeys.main.save') as string;
|
||||
const recordShortcut = configManager.get('hotkeys.main.record') as string;
|
||||
|
||||
// Check for undo shortcut
|
||||
if (undoShortcut && matchesKeyboardShortcut(event, undoShortcut)) {
|
||||
@@ -153,6 +157,42 @@ export const useGlobalKeyboardHandler = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for record toggle shortcut
|
||||
if (recordShortcut && matchesKeyboardShortcut(event, recordShortcut)) {
|
||||
event.preventDefault();
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
setStatus('Recording stopped — notes committed');
|
||||
return;
|
||||
}
|
||||
const candidateId = activeRegionId ?? (selectedRegionIds[0] ?? null);
|
||||
if (!candidateId) {
|
||||
setStatus('Select a MIDI region before recording');
|
||||
return;
|
||||
}
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
let isMidi = false;
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(r => r.getId() === candidateId);
|
||||
if (region) { isMidi = region instanceof KGMidiRegion; break; }
|
||||
}
|
||||
if (!isMidi) {
|
||||
setStatus('Select a MIDI region before recording');
|
||||
return;
|
||||
}
|
||||
if (KGMidiInput.instance().getConnectedInputCount() === 0) {
|
||||
setStatus('No MIDI device detected');
|
||||
return;
|
||||
}
|
||||
if (!activeRegionId) {
|
||||
setActiveRegionId(candidateId);
|
||||
setShowPianoRoll(true);
|
||||
}
|
||||
startRecording();
|
||||
setStatus('Recording started...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for save shortcut
|
||||
if (saveShortcut && matchesKeyboardShortcut(event, saveShortcut)) {
|
||||
event.preventDefault();
|
||||
@@ -176,5 +216,5 @@ export const useGlobalKeyboardHandler = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName]); // Include dependencies for store actions
|
||||
}, [undo, redo, setStatus, isPlaying, startPlaying, stopPlaying, toggleLoop, projectName, isRecording, startRecording, stopRecording, activeRegionId, selectedRegionIds, setActiveRegionId, setShowPianoRoll]); // Include dependencies for store actions
|
||||
};
|
||||
+123
-4
@@ -18,6 +18,10 @@ import { upgradeProjectToLatest } from '../core/project-upgrader/KGProjectUpgrad
|
||||
import { toggleLoop } from '../util/loopUtil';
|
||||
import { TOOLBAR_CONSTANTS } from '../constants/uiConstants';
|
||||
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
|
||||
@@ -92,6 +96,12 @@ interface ProjectState {
|
||||
// Settings state
|
||||
showSettings: boolean;
|
||||
|
||||
// Recording state
|
||||
isRecording: boolean;
|
||||
recordingTargetRegionId: string | null;
|
||||
recordingNotes: Array<{ pitch: number; startBeat: number; endBeat: number }>;
|
||||
recordingOriginalPlayhead: number;
|
||||
|
||||
// Undo/redo state
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@@ -167,11 +177,19 @@ interface ProjectState {
|
||||
|
||||
// Project state refresh actions
|
||||
refreshProjectState: () => void;
|
||||
|
||||
|
||||
// Recording actions
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => Promise<void>;
|
||||
|
||||
// Initialization actions
|
||||
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
|
||||
export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
const currentProject = KGCore.instance().getCurrentProject();
|
||||
@@ -308,7 +326,13 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
canRedo: false,
|
||||
undoDescription: null,
|
||||
redoDescription: null,
|
||||
|
||||
|
||||
// Initial recording state
|
||||
isRecording: false,
|
||||
recordingTargetRegionId: null,
|
||||
recordingNotes: [],
|
||||
recordingOriginalPlayhead: 0,
|
||||
|
||||
// Actions
|
||||
setProjectName: (name: string) => {
|
||||
try {
|
||||
@@ -772,6 +796,102 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
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: () => {
|
||||
const { isLooping, loopingRange, maxBars, isPlaying, stopPlaying } = get();
|
||||
|
||||
@@ -1077,7 +1197,7 @@ export const useProjectStore = create<ProjectState>((set, get) => {
|
||||
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
|
||||
* Tests the complete flow: Command execution → Core model updates → UI state sync
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
// Import core classes
|
||||
import { KGCore } from '../../../core/KGCore'
|
||||
import { KGProject } from '../../../core/KGProject'
|
||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack'
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote'
|
||||
import { KGCommandHistory } from '../../../core/commands/KGCommandHistory'
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { KGProject } from '../../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote';
|
||||
import { KGCommandHistory } from '../../../core/commands/KGCommandHistory';
|
||||
|
||||
// Import commands
|
||||
import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand'
|
||||
import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand'
|
||||
import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand'
|
||||
import { CreateNoteCommand } from '../../../core/commands/note/CreateNoteCommand';
|
||||
import { DeleteNotesCommand } from '../../../core/commands/note/DeleteNotesCommand';
|
||||
import { AddTrackCommand } from '../../../core/commands/track/AddTrackCommand';
|
||||
|
||||
// Import store
|
||||
import { useProjectStore } from '../../../stores/projectStore'
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
// Import test utilities
|
||||
import '../../utils/setup-integration-tests'
|
||||
import '../../utils/setup-integration-tests';
|
||||
|
||||
describe('Command Execution Integration Tests', () => {
|
||||
let testProject: KGProject
|
||||
let testTrack: KGMidiTrack
|
||||
let testRegion: KGMidiRegion
|
||||
let testProject: KGProject;
|
||||
let testTrack: KGMidiTrack;
|
||||
let testRegion: KGMidiRegion;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a real project with track and region for testing
|
||||
testProject = new KGProject('Test Project')
|
||||
testProject.setBpm(120)
|
||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 })
|
||||
testProject.setKeySignature('C major')
|
||||
testProject.setMaxBars(32)
|
||||
testProject = new KGProject('Test Project');
|
||||
testProject.setBpm(120);
|
||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 });
|
||||
testProject.setKeySignature('C major');
|
||||
testProject.setMaxBars(32);
|
||||
|
||||
// Create test track and region
|
||||
testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
||||
testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
|
||||
testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||
testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
|
||||
|
||||
// Set up the project hierarchy
|
||||
testTrack.addRegion(testRegion)
|
||||
testProject.setTracks([testTrack])
|
||||
testTrack.addRegion(testRegion);
|
||||
testProject.setTracks([testTrack]);
|
||||
|
||||
// Initialize KGCore with test project
|
||||
const core = KGCore.instance()
|
||||
await core.initialize()
|
||||
core.setCurrentProject(testProject)
|
||||
const core = KGCore.instance();
|
||||
await core.initialize();
|
||||
core.setCurrentProject(testProject);
|
||||
|
||||
// Clear command history
|
||||
KGCommandHistory.instance().clear()
|
||||
KGCommandHistory.instance().clear();
|
||||
|
||||
// Initialize store with project
|
||||
const { loadProject } = useProjectStore.getState()
|
||||
await loadProject(testProject)
|
||||
})
|
||||
const { loadProject } = useProjectStore.getState();
|
||||
await loadProject(testProject);
|
||||
});
|
||||
|
||||
describe('Note Command Integration', () => {
|
||||
it('should execute CreateNoteCommand and update both core model and store', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const initialNoteCount = testRegion.getNotes().length
|
||||
const regionId = testRegion.getId();
|
||||
const initialNoteCount = testRegion.getNotes().length;
|
||||
|
||||
// Create and execute command
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
// Execute command through command history (simulates real usage)
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createCommand)
|
||||
})
|
||||
commandHistory.executeCommand(createCommand);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1)
|
||||
const updatedRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(updatedRegion.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||
|
||||
const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId())
|
||||
expect(createdNote).toBeDefined()
|
||||
expect(createdNote!.getPitch()).toBe(60)
|
||||
expect(createdNote!.getStartBeat()).toBe(0)
|
||||
expect(createdNote!.getEndBeat()).toBe(1)
|
||||
const createdNote = updatedRegion.getNotes().find(note => note.getId() === createCommand.getNoteId());
|
||||
expect(createdNote).toBeDefined();
|
||||
expect(createdNote!.getPitch()).toBe(60);
|
||||
expect(createdNote!.getStartBeat()).toBe(0);
|
||||
expect(createdNote!.getEndBeat()).toBe(1);
|
||||
|
||||
// Verify command history state
|
||||
expect(commandHistory.canUndo()).toBe(true)
|
||||
expect(commandHistory.canRedo()).toBe(false)
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note C4')
|
||||
expect(commandHistory.canUndo()).toBe(true);
|
||||
expect(commandHistory.canRedo()).toBe(false);
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note C4');
|
||||
|
||||
// Verify store undo/redo state is updated
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.canUndo).toBe(true)
|
||||
expect(storeState.canRedo).toBe(false)
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.canUndo).toBe(true);
|
||||
expect(storeState.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('should execute undo and restore previous state', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const initialNoteCount = testRegion.getNotes().length
|
||||
const regionId = testRegion.getId();
|
||||
const initialNoteCount = testRegion.getNotes().length;
|
||||
|
||||
// Create and execute command
|
||||
const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120) // E4
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const createCommand = new CreateNoteCommand(regionId, 2, 3, 64, 120); // E4
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createCommand)
|
||||
})
|
||||
commandHistory.executeCommand(createCommand);
|
||||
});
|
||||
|
||||
// Verify note was created
|
||||
const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1)
|
||||
const regionAfterCreate = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(regionAfterCreate.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||
|
||||
// Execute undo
|
||||
act(() => {
|
||||
const undoSuccess = commandHistory.undo()
|
||||
expect(undoSuccess).toBe(true)
|
||||
})
|
||||
const undoSuccess = commandHistory.undo();
|
||||
expect(undoSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Verify core model was restored
|
||||
const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount)
|
||||
const regionAfterUndo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(regionAfterUndo.getNotes()).toHaveLength(initialNoteCount);
|
||||
|
||||
// Verify the specific note was removed
|
||||
const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId())
|
||||
expect(noteExists).toBe(false)
|
||||
const noteExists = regionAfterUndo.getNotes().some(note => note.getId() === createCommand.getNoteId());
|
||||
expect(noteExists).toBe(false);
|
||||
|
||||
// Verify command history state
|
||||
expect(commandHistory.canUndo()).toBe(false)
|
||||
expect(commandHistory.canRedo()).toBe(true)
|
||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4')
|
||||
})
|
||||
expect(commandHistory.canUndo()).toBe(false);
|
||||
expect(commandHistory.canRedo()).toBe(true);
|
||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4');
|
||||
});
|
||||
|
||||
it('should execute redo and restore forward state', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const initialNoteCount = testRegion.getNotes().length
|
||||
const regionId = testRegion.getId();
|
||||
const initialNoteCount = testRegion.getNotes().length;
|
||||
|
||||
// Create, execute, and undo a command
|
||||
const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110) // G4
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const createCommand = new CreateNoteCommand(regionId, 1, 2, 67, 110); // G4
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createCommand)
|
||||
commandHistory.undo()
|
||||
})
|
||||
commandHistory.executeCommand(createCommand);
|
||||
commandHistory.undo();
|
||||
});
|
||||
|
||||
// Verify we're back to initial state
|
||||
expect(testRegion.getNotes()).toHaveLength(initialNoteCount)
|
||||
expect(testRegion.getNotes()).toHaveLength(initialNoteCount);
|
||||
|
||||
// Execute redo
|
||||
act(() => {
|
||||
const redoSuccess = commandHistory.redo()
|
||||
expect(redoSuccess).toBe(true)
|
||||
})
|
||||
const redoSuccess = commandHistory.redo();
|
||||
expect(redoSuccess).toBe(true);
|
||||
});
|
||||
|
||||
// Verify core model was restored to post-create state
|
||||
const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1)
|
||||
const regionAfterRedo = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(regionAfterRedo.getNotes()).toHaveLength(initialNoteCount + 1);
|
||||
|
||||
// Verify the specific note was recreated
|
||||
const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId())
|
||||
expect(recreatedNote).toBeDefined()
|
||||
expect(recreatedNote!.getPitch()).toBe(67)
|
||||
const recreatedNote = regionAfterRedo.getNotes().find(note => note.getId() === createCommand.getNoteId());
|
||||
expect(recreatedNote).toBeDefined();
|
||||
expect(recreatedNote!.getPitch()).toBe(67);
|
||||
|
||||
// Verify command history state
|
||||
expect(commandHistory.canUndo()).toBe(true)
|
||||
expect(commandHistory.canRedo()).toBe(false)
|
||||
})
|
||||
})
|
||||
expect(commandHistory.canUndo()).toBe(true);
|
||||
expect(commandHistory.canRedo()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Command Integration', () => {
|
||||
it('should execute multiple commands and maintain history integrity', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const regionId = testRegion.getId();
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
// Execute multiple note creation commands
|
||||
const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100) // C4
|
||||
const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100) // E4
|
||||
const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100) // G4
|
||||
const command1 = new CreateNoteCommand(regionId, 0, 1, 60, 100); // C4
|
||||
const command2 = new CreateNoteCommand(regionId, 1, 2, 64, 100); // E4
|
||||
const command3 = new CreateNoteCommand(regionId, 2, 3, 67, 100); // G4
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(command1)
|
||||
commandHistory.executeCommand(command2)
|
||||
commandHistory.executeCommand(command3)
|
||||
})
|
||||
commandHistory.executeCommand(command1);
|
||||
commandHistory.executeCommand(command2);
|
||||
commandHistory.executeCommand(command3);
|
||||
});
|
||||
|
||||
// Verify all notes were created
|
||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(region.getNotes()).toHaveLength(3)
|
||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(region.getNotes()).toHaveLength(3);
|
||||
|
||||
// Verify command history
|
||||
expect(commandHistory.canUndo()).toBe(true)
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note G4')
|
||||
expect(commandHistory.canUndo()).toBe(true);
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note G4');
|
||||
|
||||
// Undo middle command by undoing twice
|
||||
act(() => {
|
||||
commandHistory.undo() // Remove G4
|
||||
commandHistory.undo() // Remove E4
|
||||
})
|
||||
commandHistory.undo(); // Remove G4
|
||||
commandHistory.undo(); // Remove E4
|
||||
});
|
||||
|
||||
// Verify only first note remains
|
||||
expect(region.getNotes()).toHaveLength(1)
|
||||
expect(region.getNotes()[0].getPitch()).toBe(60) // C4
|
||||
expect(region.getNotes()).toHaveLength(1);
|
||||
expect(region.getNotes()[0].getPitch()).toBe(60); // C4
|
||||
|
||||
// Verify redo state
|
||||
expect(commandHistory.canRedo()).toBe(true)
|
||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4')
|
||||
})
|
||||
expect(commandHistory.canRedo()).toBe(true);
|
||||
expect(commandHistory.getRedoDescription()).toBe('Create note E4');
|
||||
});
|
||||
|
||||
it('should handle command execution with different command types', async () => {
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const initialTrackCount = testProject.getTracks().length
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
const initialTrackCount = testProject.getTracks().length;
|
||||
|
||||
// Execute track addition command
|
||||
const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass')
|
||||
const addTrackCommand = new AddTrackCommand(1, 'Bass Track', 'acoustic_bass');
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(addTrackCommand)
|
||||
})
|
||||
commandHistory.executeCommand(addTrackCommand);
|
||||
});
|
||||
|
||||
// Verify track was added to core model
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1)
|
||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack
|
||||
expect(newTrack.getName()).toBe('Bass Track')
|
||||
expect(newTrack.getInstrument()).toBe('acoustic_bass')
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
|
||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
|
||||
expect(newTrack.getName()).toBe('Bass Track');
|
||||
expect(newTrack.getInstrument()).toBe('acoustic_bass');
|
||||
|
||||
// Add a note to the original region
|
||||
const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100) // C3
|
||||
const createNoteCommand = new CreateNoteCommand(testRegion.getId(), 0, 1, 48, 100); // C3
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createNoteCommand)
|
||||
})
|
||||
commandHistory.executeCommand(createNoteCommand);
|
||||
});
|
||||
|
||||
// Verify both commands are in history
|
||||
expect(commandHistory.canUndo()).toBe(true)
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note C3')
|
||||
expect(commandHistory.canUndo()).toBe(true);
|
||||
expect(commandHistory.getUndoDescription()).toBe('Create note C3');
|
||||
|
||||
// Undo both commands
|
||||
act(() => {
|
||||
commandHistory.undo() // Undo note creation
|
||||
commandHistory.undo() // Undo track addition
|
||||
})
|
||||
commandHistory.undo(); // Undo note creation
|
||||
commandHistory.undo(); // Undo track addition
|
||||
});
|
||||
|
||||
// Verify both operations were undone
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount)
|
||||
const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(originalRegion.getNotes()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount);
|
||||
const originalRegion = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(originalRegion.getNotes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Integration', () => {
|
||||
it('should handle command execution errors gracefully', async () => {
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
// Try to create note in non-existent region
|
||||
const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100)
|
||||
const invalidCommand = new CreateNoteCommand('invalid-region-id', 0, 1, 60, 100);
|
||||
|
||||
// Execute command - should not throw but should not add to history
|
||||
act(() => {
|
||||
commandHistory.executeCommand(invalidCommand)
|
||||
})
|
||||
commandHistory.executeCommand(invalidCommand);
|
||||
});
|
||||
|
||||
// Verify command was not added to history due to execution failure
|
||||
expect(commandHistory.canUndo()).toBe(false)
|
||||
expect(commandHistory.getUndoDescription()).toBeNull()
|
||||
expect(commandHistory.canUndo()).toBe(false);
|
||||
expect(commandHistory.getUndoDescription()).toBeNull();
|
||||
|
||||
// Verify project state unchanged
|
||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion
|
||||
expect(region.getNotes()).toHaveLength(0)
|
||||
})
|
||||
const region = testProject.getTracks()[0].getRegions()[0] as KGMidiRegion;
|
||||
expect(region.getNotes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle undo failures gracefully', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const regionId = testRegion.getId();
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
|
||||
// Create a command that will succeed initially
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createCommand)
|
||||
})
|
||||
commandHistory.executeCommand(createCommand);
|
||||
});
|
||||
|
||||
// Manually remove the region to cause undo to fail
|
||||
testTrack.removeRegion(testRegion.getId())
|
||||
testTrack.removeRegion(testRegion.getId());
|
||||
|
||||
// Try to undo - should fail gracefully
|
||||
act(() => {
|
||||
const undoSuccess = commandHistory.undo()
|
||||
expect(undoSuccess).toBe(false)
|
||||
})
|
||||
const undoSuccess = commandHistory.undo();
|
||||
expect(undoSuccess).toBe(false);
|
||||
});
|
||||
|
||||
// Verify command is still in undo stack after failed undo
|
||||
expect(commandHistory.canUndo()).toBe(true)
|
||||
})
|
||||
})
|
||||
expect(commandHistory.canUndo()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Store Integration', () => {
|
||||
it('should keep store undo/redo state synchronized with command history', async () => {
|
||||
const regionId = testRegion.getId()
|
||||
const commandHistory = KGCommandHistory.instance()
|
||||
const { syncUndoRedoState } = useProjectStore.getState()
|
||||
const regionId = testRegion.getId();
|
||||
const commandHistory = KGCommandHistory.instance();
|
||||
const { syncUndoRedoState } = useProjectStore.getState();
|
||||
|
||||
// Initial state
|
||||
expect(useProjectStore.getState().canUndo).toBe(false)
|
||||
expect(useProjectStore.getState().canRedo).toBe(false)
|
||||
expect(useProjectStore.getState().canUndo).toBe(false);
|
||||
expect(useProjectStore.getState().canRedo).toBe(false);
|
||||
|
||||
// Execute command
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100)
|
||||
const createCommand = new CreateNoteCommand(regionId, 0, 1, 60, 100);
|
||||
|
||||
act(() => {
|
||||
commandHistory.executeCommand(createCommand)
|
||||
syncUndoRedoState() // Simulate store sync
|
||||
})
|
||||
commandHistory.executeCommand(createCommand);
|
||||
syncUndoRedoState(); // Simulate store sync
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
let storeState = useProjectStore.getState()
|
||||
expect(storeState.canUndo).toBe(true)
|
||||
expect(storeState.canRedo).toBe(false)
|
||||
expect(storeState.undoDescription).toBe('Create note C4')
|
||||
expect(storeState.redoDescription).toBeNull()
|
||||
let storeState = useProjectStore.getState();
|
||||
expect(storeState.canUndo).toBe(true);
|
||||
expect(storeState.canRedo).toBe(false);
|
||||
expect(storeState.undoDescription).toBe('Create note C4');
|
||||
expect(storeState.redoDescription).toBeNull();
|
||||
|
||||
// Execute undo
|
||||
act(() => {
|
||||
commandHistory.undo()
|
||||
syncUndoRedoState() // Simulate store sync
|
||||
})
|
||||
commandHistory.undo();
|
||||
syncUndoRedoState(); // Simulate store sync
|
||||
});
|
||||
|
||||
// Verify store state updated after undo
|
||||
storeState = useProjectStore.getState()
|
||||
expect(storeState.canUndo).toBe(false)
|
||||
expect(storeState.canRedo).toBe(true)
|
||||
expect(storeState.undoDescription).toBeNull()
|
||||
expect(storeState.redoDescription).toBe('Create note C4')
|
||||
})
|
||||
})
|
||||
})
|
||||
storeState = useProjectStore.getState();
|
||||
expect(storeState.canUndo).toBe(false);
|
||||
expect(storeState.canRedo).toBe(true);
|
||||
expect(storeState.undoDescription).toBeNull();
|
||||
expect(storeState.redoDescription).toBe('Create note C4');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,445 +2,481 @@
|
||||
* Integration tests for project store synchronization with core models
|
||||
* Tests the critical data flow: Store Actions → Core Models → UI State Updates
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
// Import core classes
|
||||
import { KGCore } from '../../../core/KGCore'
|
||||
import { KGProject } from '../../../core/KGProject'
|
||||
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack'
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote'
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { KGProject } from '../../../core/KGProject';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../../core/midi/KGMidiNote';
|
||||
import { KGMidiInput } from '../../../core/midi-input/KGMidiInput';
|
||||
|
||||
// Import store
|
||||
import { useProjectStore } from '../../../stores/projectStore'
|
||||
import { useProjectStore } from '../../../stores/projectStore';
|
||||
|
||||
// Import test utilities and mocks
|
||||
import '../../utils/setup-integration-tests'
|
||||
import { mockAudioInterface } from '../../mocks/audio-interface'
|
||||
import '../../utils/setup-integration-tests';
|
||||
import { mockAudioInterface } from '../../mocks/audio-interface';
|
||||
|
||||
describe('Project Store Synchronization Integration Tests', () => {
|
||||
let testProject: KGProject
|
||||
let testProject: KGProject;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a real project for testing
|
||||
testProject = new KGProject('Sync Test Project')
|
||||
testProject.setBpm(120)
|
||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 })
|
||||
testProject.setKeySignature('C major')
|
||||
testProject.setMaxBars(32)
|
||||
testProject = new KGProject('Sync Test Project');
|
||||
testProject.setBpm(120);
|
||||
testProject.setTimeSignature({ numerator: 4, denominator: 4 });
|
||||
testProject.setKeySignature('C major');
|
||||
testProject.setMaxBars(32);
|
||||
|
||||
// Initialize KGCore
|
||||
const core = KGCore.instance()
|
||||
await core.initialize()
|
||||
core.setCurrentProject(testProject)
|
||||
const core = KGCore.instance();
|
||||
await core.initialize();
|
||||
core.setCurrentProject(testProject);
|
||||
|
||||
// Reset store state
|
||||
const store = useProjectStore.getState()
|
||||
await store.loadProject(testProject)
|
||||
})
|
||||
const store = useProjectStore.getState();
|
||||
await store.loadProject(testProject);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Project Properties Synchronization', () => {
|
||||
it('should sync BPM changes between store and core model', async () => {
|
||||
const { setBpm } = useProjectStore.getState()
|
||||
const newBpm = 140
|
||||
const { setBpm } = useProjectStore.getState();
|
||||
const newBpm = 140;
|
||||
|
||||
// Execute store action
|
||||
act(() => {
|
||||
setBpm(newBpm)
|
||||
})
|
||||
setBpm(newBpm);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getBpm()).toBe(newBpm)
|
||||
expect(testProject.getBpm()).toBe(newBpm);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.bpm).toBe(newBpm)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.bpm).toBe(newBpm);
|
||||
|
||||
// Verify CSS custom property was updated
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
||||
expect(cssValue).toBeTruthy() // CSS should be updated by store action
|
||||
})
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||
expect(cssValue).toBeTruthy(); // CSS should be updated by store action
|
||||
});
|
||||
|
||||
it('should sync time signature changes and update CSS properties', async () => {
|
||||
const { setTimeSignature } = useProjectStore.getState()
|
||||
const newTimeSignature = { numerator: 3, denominator: 4 }
|
||||
const { setTimeSignature } = useProjectStore.getState();
|
||||
const newTimeSignature = { numerator: 3, denominator: 4 };
|
||||
|
||||
act(() => {
|
||||
setTimeSignature(newTimeSignature)
|
||||
})
|
||||
setTimeSignature(newTimeSignature);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getTimeSignature()).toEqual(newTimeSignature)
|
||||
expect(testProject.getTimeSignature()).toEqual(newTimeSignature);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.timeSignature).toEqual(newTimeSignature)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.timeSignature).toEqual(newTimeSignature);
|
||||
|
||||
// Verify CSS custom property was updated for UI calculations
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
||||
expect(cssValue.trim()).toBe('3')
|
||||
})
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||
expect(cssValue.trim()).toBe('3');
|
||||
});
|
||||
|
||||
it('should sync max bars changes and update layout CSS', async () => {
|
||||
const { setMaxBars } = useProjectStore.getState()
|
||||
const newMaxBars = 64
|
||||
const { setMaxBars } = useProjectStore.getState();
|
||||
const newMaxBars = 64;
|
||||
|
||||
act(() => {
|
||||
setMaxBars(newMaxBars)
|
||||
})
|
||||
setMaxBars(newMaxBars);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getMaxBars()).toBe(newMaxBars)
|
||||
expect(testProject.getMaxBars()).toBe(newMaxBars);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.maxBars).toBe(newMaxBars)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.maxBars).toBe(newMaxBars);
|
||||
|
||||
// Verify CSS custom property was updated for layout
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars')
|
||||
expect(cssValue.trim()).toBe('64')
|
||||
})
|
||||
const cssValue = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
|
||||
expect(cssValue.trim()).toBe('64');
|
||||
});
|
||||
|
||||
it('should sync key signature changes', async () => {
|
||||
const { setKeySignature } = useProjectStore.getState()
|
||||
const newKeySignature = 'G major'
|
||||
const { setKeySignature } = useProjectStore.getState();
|
||||
const newKeySignature = 'G major';
|
||||
|
||||
act(() => {
|
||||
setKeySignature(newKeySignature)
|
||||
})
|
||||
setKeySignature(newKeySignature);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getKeySignature()).toBe(newKeySignature)
|
||||
expect(testProject.getKeySignature()).toBe(newKeySignature);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.keySignature).toBe(newKeySignature)
|
||||
})
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.keySignature).toBe(newKeySignature);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Track Management Synchronization', () => {
|
||||
it('should sync track addition with core model and audio interface', async () => {
|
||||
const { addTrack } = useProjectStore.getState()
|
||||
const initialTrackCount = testProject.getTracks().length
|
||||
const { addTrack } = useProjectStore.getState();
|
||||
const initialTrackCount = testProject.getTracks().length;
|
||||
|
||||
// Execute store action
|
||||
await act(async () => {
|
||||
await addTrack()
|
||||
})
|
||||
await addTrack();
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1)
|
||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack
|
||||
expect(newTrack).toBeInstanceOf(KGMidiTrack)
|
||||
expect(newTrack.getName()).toContain('Track')
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount + 1);
|
||||
const newTrack = testProject.getTracks()[initialTrackCount] as KGMidiTrack;
|
||||
expect(newTrack).toBeInstanceOf(KGMidiTrack);
|
||||
expect(newTrack.getName()).toContain('Track');
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.tracks).toHaveLength(initialTrackCount + 1)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.tracks).toHaveLength(initialTrackCount + 1);
|
||||
|
||||
// Verify audio interface was notified
|
||||
expect(mockAudioInterface.createTrackBus).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockAudioInterface.createTrackBus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sync track removal with core model and audio interface', async () => {
|
||||
// First add a track
|
||||
const { addTrack, removeTrack } = useProjectStore.getState()
|
||||
const { addTrack, removeTrack } = useProjectStore.getState();
|
||||
|
||||
await act(async () => {
|
||||
await addTrack()
|
||||
})
|
||||
await addTrack();
|
||||
});
|
||||
|
||||
const trackCountAfterAdd = testProject.getTracks().length
|
||||
const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1]
|
||||
const trackCountAfterAdd = testProject.getTracks().length;
|
||||
const trackToRemove = testProject.getTracks()[trackCountAfterAdd - 1];
|
||||
|
||||
// Remove the track
|
||||
await act(async () => {
|
||||
await removeTrack(trackCountAfterAdd - 1) // Remove last track
|
||||
})
|
||||
await removeTrack(trackCountAfterAdd - 1); // Remove last track
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1)
|
||||
expect(testProject.getTracks()).toHaveLength(trackCountAfterAdd - 1);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.tracks).toHaveLength(trackCountAfterAdd - 1);
|
||||
|
||||
// Verify audio interface was notified
|
||||
expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId())
|
||||
})
|
||||
expect(mockAudioInterface.removeTrackBus).toHaveBeenCalledWith(trackToRemove.getId());
|
||||
});
|
||||
|
||||
it('should sync track instrument changes with audio interface', async () => {
|
||||
// Add a track first
|
||||
const { addTrack, setTrackInstrument } = useProjectStore.getState()
|
||||
const { addTrack, setTrackInstrument } = useProjectStore.getState();
|
||||
|
||||
await act(async () => {
|
||||
await addTrack()
|
||||
})
|
||||
await addTrack();
|
||||
});
|
||||
|
||||
const trackIndex = testProject.getTracks().length - 1
|
||||
const track = testProject.getTracks()[trackIndex] as KGMidiTrack
|
||||
const newInstrument: InstrumentType = 'electric_bass'
|
||||
const trackIndex = testProject.getTracks().length - 1;
|
||||
const track = testProject.getTracks()[trackIndex] as KGMidiTrack;
|
||||
const newInstrument: InstrumentType = 'electric_bass';
|
||||
|
||||
// Change track instrument
|
||||
await act(async () => {
|
||||
await setTrackInstrument(trackIndex, newInstrument)
|
||||
})
|
||||
await setTrackInstrument(trackIndex, newInstrument);
|
||||
});
|
||||
|
||||
// Verify core model was updated
|
||||
expect(track.getInstrument()).toBe(newInstrument)
|
||||
expect(track.getInstrument()).toBe(newInstrument);
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack
|
||||
expect(storeTrack.getInstrument()).toBe(newInstrument)
|
||||
const storeState = useProjectStore.getState();
|
||||
const storeTrack = storeState.tracks[trackIndex] as KGMidiTrack;
|
||||
expect(storeTrack.getInstrument()).toBe(newInstrument);
|
||||
|
||||
// Verify audio interface was notified
|
||||
expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument)
|
||||
})
|
||||
expect(mockAudioInterface.setTrackInstrument).toHaveBeenCalledWith(track.getId(), newInstrument);
|
||||
});
|
||||
|
||||
it('should sync track reordering with core model', async () => {
|
||||
const { addTrack, reorderTracks } = useProjectStore.getState()
|
||||
const { addTrack, reorderTracks } = useProjectStore.getState();
|
||||
|
||||
// Add two tracks
|
||||
await act(async () => {
|
||||
await addTrack() // Track at index 0
|
||||
await addTrack() // Track at index 1
|
||||
})
|
||||
await addTrack(); // Track at index 0
|
||||
await addTrack(); // Track at index 1
|
||||
});
|
||||
|
||||
const track0Before = testProject.getTracks()[0]
|
||||
const track1Before = testProject.getTracks()[1]
|
||||
const track0Before = testProject.getTracks()[0];
|
||||
const track1Before = testProject.getTracks()[1];
|
||||
|
||||
// Reorder tracks (move track 0 to position 1)
|
||||
act(() => {
|
||||
reorderTracks(0, 1)
|
||||
})
|
||||
reorderTracks(0, 1);
|
||||
});
|
||||
|
||||
// Verify core model track order changed
|
||||
const track0After = testProject.getTracks()[0]
|
||||
const track1After = testProject.getTracks()[1]
|
||||
const track0After = testProject.getTracks()[0];
|
||||
const track1After = testProject.getTracks()[1];
|
||||
|
||||
expect(track0After.getId()).toBe(track1Before.getId())
|
||||
expect(track1After.getId()).toBe(track0Before.getId())
|
||||
expect(track0After.getId()).toBe(track1Before.getId());
|
||||
expect(track1After.getId()).toBe(track0Before.getId());
|
||||
|
||||
// Verify store state reflects change
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.tracks[0].getId()).toBe(track1Before.getId())
|
||||
expect(storeState.tracks[1].getId()).toBe(track0Before.getId())
|
||||
})
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.tracks[0].getId()).toBe(track1Before.getId());
|
||||
expect(storeState.tracks[1].getId()).toBe(track0Before.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Playback State Synchronization', () => {
|
||||
it('should sync playhead position with formatted time string', async () => {
|
||||
const { setPlayheadPosition } = useProjectStore.getState()
|
||||
const newPosition = 8.5 // beats
|
||||
const { setPlayheadPosition } = useProjectStore.getState();
|
||||
const newPosition = 8.5; // beats
|
||||
|
||||
act(() => {
|
||||
setPlayheadPosition(newPosition)
|
||||
})
|
||||
setPlayheadPosition(newPosition);
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.playheadPosition).toBe(newPosition)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.playheadPosition).toBe(newPosition);
|
||||
|
||||
// Verify formatted time string was updated
|
||||
expect(storeState.currentTime).toBeDefined()
|
||||
expect(storeState.currentTime).toContain('|') // Should contain BBB:B | mm:ss:mmm format
|
||||
})
|
||||
expect(storeState.currentTime).toBeDefined();
|
||||
expect(storeState.currentTime).toContain('|'); // Should contain BBB:B | mm:ss:mmm format
|
||||
});
|
||||
|
||||
it('should sync playback state changes', async () => {
|
||||
const { startPlaying, stopPlaying } = useProjectStore.getState()
|
||||
const { startPlaying, stopPlaying } = useProjectStore.getState();
|
||||
|
||||
// Start playing
|
||||
await act(async () => {
|
||||
await startPlaying()
|
||||
})
|
||||
await startPlaying();
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
let storeState = useProjectStore.getState()
|
||||
expect(storeState.isPlaying).toBe(true)
|
||||
let storeState = useProjectStore.getState();
|
||||
expect(storeState.isPlaying).toBe(true);
|
||||
|
||||
// Verify audio interface was called
|
||||
expect(mockAudioInterface.startPlayback).toHaveBeenCalled()
|
||||
expect(mockAudioInterface.startPlayback).toHaveBeenCalled();
|
||||
|
||||
// Stop playing
|
||||
await act(async () => {
|
||||
await stopPlaying()
|
||||
})
|
||||
await stopPlaying();
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
storeState = useProjectStore.getState()
|
||||
expect(storeState.isPlaying).toBe(false)
|
||||
storeState = useProjectStore.getState();
|
||||
expect(storeState.isPlaying).toBe(false);
|
||||
|
||||
// Verify audio interface was called
|
||||
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(mockAudioInterface.stopPlayback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start looped recording one bar before the loop start on the first pass', async () => {
|
||||
const testTrack = new KGMidiTrack('Recording Track', 0, 'acoustic_grand_piano');
|
||||
const testRegion = new KGMidiRegion('record-region', 'track-0', 0, 'Recording Region', 16, 16);
|
||||
testTrack.addRegion(testRegion);
|
||||
testProject.setTracks([testTrack]);
|
||||
testProject.setIsLooping(true);
|
||||
testProject.setLoopingRange([4, 7]);
|
||||
|
||||
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', () => {
|
||||
it('should sync selection state with core piano roll state', async () => {
|
||||
const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState()
|
||||
const { setActiveRegionId, syncSelectionFromCore } = useProjectStore.getState();
|
||||
|
||||
// Add a track and region for testing
|
||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
||||
const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16)
|
||||
testTrack.addRegion(testRegion)
|
||||
testProject.setTracks([...testProject.getTracks(), testTrack])
|
||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||
const testRegion = new KGMidiRegion('region-1', 'track-0', 0, 'Test Region', 0, 16);
|
||||
testTrack.addRegion(testRegion);
|
||||
testProject.setTracks([...testProject.getTracks(), testTrack]);
|
||||
|
||||
// Set active region
|
||||
act(() => {
|
||||
setActiveRegionId(testRegion.getId())
|
||||
})
|
||||
setActiveRegionId(testRegion.getId());
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
let storeState = useProjectStore.getState()
|
||||
expect(storeState.activeRegionId).toBe(testRegion.getId())
|
||||
let storeState = useProjectStore.getState();
|
||||
expect(storeState.activeRegionId).toBe(testRegion.getId());
|
||||
|
||||
// Simulate core selection changes and sync
|
||||
act(() => {
|
||||
syncSelectionFromCore()
|
||||
})
|
||||
syncSelectionFromCore();
|
||||
});
|
||||
|
||||
// Verify store selection state is synchronized
|
||||
storeState = useProjectStore.getState()
|
||||
expect(storeState.selectedNoteIds).toBeDefined()
|
||||
expect(storeState.selectedRegionIds).toBeDefined()
|
||||
})
|
||||
storeState = useProjectStore.getState();
|
||||
expect(storeState.selectedNoteIds).toBeDefined();
|
||||
expect(storeState.selectedRegionIds).toBeDefined();
|
||||
});
|
||||
|
||||
it('should clear all selections and sync state', async () => {
|
||||
const { clearAllSelections, setSelectedTrack } = useProjectStore.getState()
|
||||
const { clearAllSelections, setSelectedTrack } = useProjectStore.getState();
|
||||
|
||||
// Set some initial selection state
|
||||
act(() => {
|
||||
setSelectedTrack('test-track-id')
|
||||
})
|
||||
setSelectedTrack('test-track-id');
|
||||
});
|
||||
|
||||
// Verify selection was set
|
||||
let storeState = useProjectStore.getState()
|
||||
expect(storeState.selectedTrackId).toBe('test-track-id')
|
||||
let storeState = useProjectStore.getState();
|
||||
expect(storeState.selectedTrackId).toBe('test-track-id');
|
||||
|
||||
// Clear all selections
|
||||
act(() => {
|
||||
clearAllSelections()
|
||||
})
|
||||
clearAllSelections();
|
||||
});
|
||||
|
||||
// Verify all selections were cleared
|
||||
storeState = useProjectStore.getState()
|
||||
expect(storeState.selectedTrackId).toBeNull()
|
||||
expect(storeState.selectedNoteIds).toEqual([])
|
||||
expect(storeState.selectedRegionIds).toEqual([])
|
||||
})
|
||||
})
|
||||
storeState = useProjectStore.getState();
|
||||
expect(storeState.selectedTrackId).toBeNull();
|
||||
expect(storeState.selectedNoteIds).toEqual([]);
|
||||
expect(storeState.selectedRegionIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Piano Roll State Integration', () => {
|
||||
it('should sync piano roll visibility and active region', async () => {
|
||||
const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState()
|
||||
const { setShowPianoRoll, setActiveRegionId } = useProjectStore.getState();
|
||||
|
||||
// Add test region
|
||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano')
|
||||
const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16)
|
||||
testTrack.addRegion(testRegion)
|
||||
testProject.setTracks([...testProject.getTracks(), testTrack])
|
||||
const testTrack = new KGMidiTrack('Test Track', 0, 'acoustic_grand_piano');
|
||||
const testRegion = new KGMidiRegion('region-2', 'track-0', 0, 'Test Region', 0, 16);
|
||||
testTrack.addRegion(testRegion);
|
||||
testProject.setTracks([...testProject.getTracks(), testTrack]);
|
||||
|
||||
// Show piano roll with active region
|
||||
act(() => {
|
||||
setActiveRegionId(testRegion.getId())
|
||||
setShowPianoRoll(true)
|
||||
})
|
||||
setActiveRegionId(testRegion.getId());
|
||||
setShowPianoRoll(true);
|
||||
});
|
||||
|
||||
// Verify store state updated
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.showPianoRoll).toBe(true)
|
||||
expect(storeState.activeRegionId).toBe(testRegion.getId())
|
||||
})
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.showPianoRoll).toBe(true);
|
||||
expect(storeState.activeRegionId).toBe(testRegion.getId());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases', () => {
|
||||
it('should handle invalid track operations gracefully', async () => {
|
||||
const { removeTrack } = useProjectStore.getState()
|
||||
const initialTrackCount = testProject.getTracks().length
|
||||
const { removeTrack } = useProjectStore.getState();
|
||||
const initialTrackCount = testProject.getTracks().length;
|
||||
|
||||
// Try to remove non-existent track
|
||||
await act(async () => {
|
||||
await removeTrack(999) // Invalid index
|
||||
})
|
||||
await removeTrack(999); // Invalid index
|
||||
});
|
||||
|
||||
// Verify project state unchanged
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount)
|
||||
expect(testProject.getTracks()).toHaveLength(initialTrackCount);
|
||||
|
||||
// Verify store state unchanged
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.tracks).toHaveLength(initialTrackCount)
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.tracks).toHaveLength(initialTrackCount);
|
||||
});
|
||||
|
||||
it('should handle concurrent state updates correctly', async () => {
|
||||
const { setBpm, setMaxBars } = useProjectStore.getState()
|
||||
const { setBpm, setMaxBars } = useProjectStore.getState();
|
||||
|
||||
// Execute multiple state updates concurrently
|
||||
await act(async () => {
|
||||
setBpm(140)
|
||||
setMaxBars(64)
|
||||
})
|
||||
setBpm(140);
|
||||
setMaxBars(64);
|
||||
});
|
||||
|
||||
// Verify both updates were applied to core model
|
||||
expect(testProject.getBpm()).toBe(140)
|
||||
expect(testProject.getMaxBars()).toBe(64)
|
||||
expect(testProject.getBpm()).toBe(140);
|
||||
expect(testProject.getMaxBars()).toBe(64);
|
||||
|
||||
// Verify store state is consistent
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.bpm).toBe(140)
|
||||
expect(storeState.maxBars).toBe(64)
|
||||
})
|
||||
})
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.bpm).toBe(140);
|
||||
expect(storeState.maxBars).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Loading Integration', () => {
|
||||
it('should completely sync store state when loading new project', async () => {
|
||||
const { loadProject } = useProjectStore.getState()
|
||||
const { loadProject } = useProjectStore.getState();
|
||||
|
||||
// Create a new project with specific properties
|
||||
const newProject = new KGProject('New Loaded Project')
|
||||
newProject.setBpm(160)
|
||||
newProject.setTimeSignature({ numerator: 6, denominator: 8 })
|
||||
newProject.setKeySignature('D major')
|
||||
newProject.setMaxBars(48)
|
||||
const newProject = new KGProject('New Loaded Project');
|
||||
newProject.setBpm(160);
|
||||
newProject.setTimeSignature({ numerator: 6, denominator: 8 });
|
||||
newProject.setKeySignature('D major');
|
||||
newProject.setMaxBars(48);
|
||||
|
||||
// Add a track with region and notes
|
||||
const track = new KGMidiTrack('Loaded Track', 0, 'violin')
|
||||
const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8)
|
||||
const note = new KGMidiNote('test-note', 0, 1, 64, 100)
|
||||
const track = new KGMidiTrack('Loaded Track', 0, 'violin');
|
||||
const region = new KGMidiRegion('loaded-region', 'track-0', 0, 'Loaded Region', 0, 8);
|
||||
const note = new KGMidiNote('test-note', 0, 1, 64, 100);
|
||||
|
||||
region.addNote(note)
|
||||
track.addRegion(region)
|
||||
newProject.setTracks([track])
|
||||
region.addNote(note);
|
||||
track.addRegion(region);
|
||||
newProject.setTracks([track]);
|
||||
|
||||
// Load the new project
|
||||
await act(async () => {
|
||||
await loadProject(newProject)
|
||||
})
|
||||
await loadProject(newProject);
|
||||
});
|
||||
|
||||
// Verify store state completely matches new project
|
||||
const storeState = useProjectStore.getState()
|
||||
expect(storeState.projectName).toBe('New Loaded Project')
|
||||
expect(storeState.bpm).toBe(160)
|
||||
expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 })
|
||||
expect(storeState.keySignature).toBe('D major')
|
||||
expect(storeState.maxBars).toBe(48)
|
||||
expect(storeState.tracks).toHaveLength(1)
|
||||
const storeState = useProjectStore.getState();
|
||||
expect(storeState.projectName).toBe('New Loaded Project');
|
||||
expect(storeState.bpm).toBe(160);
|
||||
expect(storeState.timeSignature).toEqual({ numerator: 6, denominator: 8 });
|
||||
expect(storeState.keySignature).toBe('D major');
|
||||
expect(storeState.maxBars).toBe(48);
|
||||
expect(storeState.tracks).toHaveLength(1);
|
||||
|
||||
// Verify core model is updated
|
||||
const core = KGCore.instance()
|
||||
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project')
|
||||
const core = KGCore.instance();
|
||||
expect(core.getCurrentProject()?.getName()).toBe('New Loaded Project');
|
||||
|
||||
// Verify CSS properties were updated
|
||||
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator')
|
||||
expect(timeSignatureCSS.trim()).toBe('6')
|
||||
const timeSignatureCSS = getComputedStyle(document.documentElement).getPropertyValue('--time-signature-numerator');
|
||||
expect(timeSignatureCSS.trim()).toBe('6');
|
||||
|
||||
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars')
|
||||
expect(maxBarsCSS.trim()).toBe('48')
|
||||
})
|
||||
})
|
||||
})
|
||||
const maxBarsCSS = getComputedStyle(document.documentElement).getPropertyValue('--max-number-of-bars');
|
||||
expect(maxBarsCSS.trim()).toBe('48');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil'
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion'
|
||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack'
|
||||
import { KGCore } from '../../../core/KGCore'
|
||||
import { KGProject } from '../../../core/KGProject'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { convertRegionToABCNotation } from '../../../util/abcNotationUtil';
|
||||
import { KGMidiRegion } from '../../../core/region/KGMidiRegion';
|
||||
import { KGMidiTrack } from '../../../core/track/KGMidiTrack';
|
||||
import { KGCore } from '../../../core/KGCore';
|
||||
import { KGProject } from '../../../core/KGProject';
|
||||
|
||||
// Import the test fixture
|
||||
import joyProjectData from '../../fixtures/joy-project.json'
|
||||
import joyProjectData from '../../fixtures/joy-project.json';
|
||||
|
||||
// Helper function to load project using real class-transformer deserialization (same as UI)
|
||||
function loadProjectFromJSON(projectData: Record<string, unknown>): KGProject {
|
||||
// Use the exact same deserialization process as the UI (Toolbar.tsx handleKGStudioJSONImport)
|
||||
const deserializedResult = plainToInstance(KGProject, projectData)
|
||||
const deserializedResult = plainToInstance(KGProject, projectData);
|
||||
|
||||
// Handle case where plainToInstance might return an array (same as UI)
|
||||
const deserializedProject = Array.isArray(deserializedResult)
|
||||
? deserializedResult[0] || null
|
||||
: deserializedResult
|
||||
: deserializedResult;
|
||||
|
||||
if (!deserializedProject) {
|
||||
throw new Error("Failed to deserialize project data")
|
||||
throw new Error("Failed to deserialize project data");
|
||||
}
|
||||
|
||||
return deserializedProject
|
||||
return deserializedProject;
|
||||
}
|
||||
|
||||
describe('abcNotationUtil - Integration Tests with Real Project Data', () => {
|
||||
let joyProject: KGProject
|
||||
let joyProject: KGProject;
|
||||
|
||||
beforeEach(() => {
|
||||
// Simulate the exact same process as Toolbar.tsx handleKGStudioJSONImport
|
||||
// First parse as JSON (simulating file.text() -> JSON.parse())
|
||||
const fileContent = JSON.stringify(joyProjectData)
|
||||
const projectData = JSON.parse(fileContent)
|
||||
const fileContent = JSON.stringify(joyProjectData);
|
||||
const projectData = JSON.parse(fileContent);
|
||||
|
||||
// Then deserialize using class-transformer (same as UI)
|
||||
joyProject = loadProjectFromJSON(projectData)
|
||||
joyProject = loadProjectFromJSON(projectData);
|
||||
|
||||
// Mock KGCore to return the loaded project (same as production flow)
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => joyProject
|
||||
} as unknown as KGCore)
|
||||
})
|
||||
} as unknown as KGCore);
|
||||
});
|
||||
|
||||
describe('convertRegionToABCNotation with real project data', () => {
|
||||
it('should convert joy project melody region to exact ABC notation', () => {
|
||||
// Get the melody track and region using real project structure
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
||||
expect(melodyTrack.getName()).toBe('Melody')
|
||||
expect(melodyTrack.getRegions()).toHaveLength(1)
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||
expect(melodyTrack.getName()).toBe('Melody');
|
||||
expect(melodyTrack.getRegions()).toHaveLength(1);
|
||||
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
expect(melodyRegion.getName()).toBe('Melody Region 1')
|
||||
expect(melodyRegion.getNotes()).toHaveLength(30) // Verify we have all 30 notes
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
expect(melodyRegion.getName()).toBe('Melody Region 1');
|
||||
expect(melodyRegion.getNotes()).toHaveLength(30); // Verify we have all 30 notes
|
||||
|
||||
// Convert to ABC notation using real region data
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||
|
||||
// Expected ABC notation output (exactly as provided)
|
||||
const expectedABCNotation = `X:1
|
||||
@@ -65,161 +65,161 @@ M:4/4
|
||||
L:1/4
|
||||
Q:1/4=125
|
||||
K:C
|
||||
E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |`
|
||||
E E F G | G F E D | C C D E | E3/2 D1/2 D2 | E E F G | G F E D | C C D E | D3/2 C1/2 C2 |`;
|
||||
|
||||
// Verify exact match
|
||||
expect(result).toBe(expectedABCNotation)
|
||||
})
|
||||
expect(result).toBe(expectedABCNotation);
|
||||
});
|
||||
|
||||
it('should handle empty pad chord region from real project', () => {
|
||||
// Get the pad chord track and region
|
||||
const padTrack = joyProject.getTracks()[1] as KGMidiTrack
|
||||
expect(padTrack.getName()).toBe('Pad Chord')
|
||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
|
||||
const padTrack = joyProject.getTracks()[1] as KGMidiTrack;
|
||||
expect(padTrack.getName()).toBe('Pad Chord');
|
||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age');
|
||||
|
||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
|
||||
expect(padRegion.getName()).toBe('Pad Chord Region 1')
|
||||
expect(padRegion.getNotes()).toHaveLength(0) // Empty region
|
||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
|
||||
expect(padRegion.getName()).toBe('Pad Chord Region 1');
|
||||
expect(padRegion.getNotes()).toHaveLength(0); // Empty region
|
||||
|
||||
// Convert empty region to ABC notation
|
||||
const result = convertRegionToABCNotation(padRegion, 0, 32)
|
||||
const result = convertRegionToABCNotation(padRegion, 0, 32);
|
||||
|
||||
// Verify it contains rest notation and proper headers
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('T:Pad Chord Region 1')
|
||||
expect(result).toContain('M:4/4')
|
||||
expect(result).toContain('Q:1/4=125')
|
||||
expect(result).toContain('K:C')
|
||||
expect(result).toContain('z') // Should contain rest notation
|
||||
})
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('T:Pad Chord Region 1');
|
||||
expect(result).toContain('M:4/4');
|
||||
expect(result).toContain('Q:1/4=125');
|
||||
expect(result).toContain('K:C');
|
||||
expect(result).toContain('z'); // Should contain rest notation
|
||||
});
|
||||
|
||||
it('should use correct project settings from real project data', () => {
|
||||
// Verify project settings are loaded correctly
|
||||
expect(joyProject.getName()).toBe('joy')
|
||||
expect(joyProject.getBpm()).toBe(125)
|
||||
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 })
|
||||
expect(joyProject.getKeySignature()).toBe('C major')
|
||||
expect(joyProject.getMaxBars()).toBe(32)
|
||||
expect(joyProject.getName()).toBe('joy');
|
||||
expect(joyProject.getBpm()).toBe(125);
|
||||
expect(joyProject.getTimeSignature()).toEqual({ numerator: 4, denominator: 4 });
|
||||
expect(joyProject.getKeySignature()).toBe('C major');
|
||||
expect(joyProject.getMaxBars()).toBe(32);
|
||||
|
||||
// These settings should be reflected in ABC notation headers
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||
|
||||
expect(result).toContain('Q:1/4=125') // BPM
|
||||
expect(result).toContain('M:4/4') // Time signature
|
||||
expect(result).toContain('K:C') // Key signature
|
||||
})
|
||||
expect(result).toContain('Q:1/4=125'); // BPM
|
||||
expect(result).toContain('M:4/4'); // Time signature
|
||||
expect(result).toContain('K:C'); // Key signature
|
||||
});
|
||||
|
||||
it('should handle real note timing and pitches correctly', () => {
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
const notes = melodyRegion.getNotes()
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
const notes = melodyRegion.getNotes();
|
||||
|
||||
// Verify some key notes from the real data
|
||||
expect(notes[0].getPitch()).toBe(64) // First note is E (64)
|
||||
expect(notes[0].getStartBeat()).toBe(0)
|
||||
expect(notes[0].getEndBeat()).toBe(1)
|
||||
expect(notes[0].getPitch()).toBe(64); // First note is E (64)
|
||||
expect(notes[0].getStartBeat()).toBe(0);
|
||||
expect(notes[0].getEndBeat()).toBe(1);
|
||||
|
||||
expect(notes[3].getPitch()).toBe(67) // Fourth note is G (67)
|
||||
expect(notes[3].getStartBeat()).toBe(3)
|
||||
expect(notes[3].getEndBeat()).toBe(4)
|
||||
expect(notes[3].getPitch()).toBe(67); // Fourth note is G (67)
|
||||
expect(notes[3].getStartBeat()).toBe(3);
|
||||
expect(notes[3].getEndBeat()).toBe(4);
|
||||
|
||||
// Verify fractional timing note (beat 12-13.5)
|
||||
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5)
|
||||
expect(fractionalNote).toBeDefined()
|
||||
expect(fractionalNote!.getPitch()).toBe(64) // E
|
||||
expect(fractionalNote!.getStartBeat()).toBe(12)
|
||||
const fractionalNote = notes.find(note => note.getEndBeat() === 13.5);
|
||||
expect(fractionalNote).toBeDefined();
|
||||
expect(fractionalNote!.getPitch()).toBe(64); // E
|
||||
expect(fractionalNote!.getStartBeat()).toBe(12);
|
||||
|
||||
// Convert and verify these real timings are reflected in ABC notation
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32)
|
||||
expect(result).toContain('E3/2 D1/2') // Fractional timing should appear in ABC
|
||||
})
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||
expect(result).toContain('E3/2 D1/2'); // Fractional timing should appear in ABC
|
||||
});
|
||||
|
||||
it('should handle multiple tracks with different instruments', () => {
|
||||
const tracks = joyProject.getTracks()
|
||||
expect(tracks).toHaveLength(2)
|
||||
const tracks = joyProject.getTracks();
|
||||
expect(tracks).toHaveLength(2);
|
||||
|
||||
// Verify track properties from real project
|
||||
const melodyTrack = tracks[0] as KGMidiTrack
|
||||
expect(melodyTrack.getName()).toBe('Melody')
|
||||
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano')
|
||||
expect(melodyTrack.getVolume()).toBe(0.8)
|
||||
const melodyTrack = tracks[0] as KGMidiTrack;
|
||||
expect(melodyTrack.getName()).toBe('Melody');
|
||||
expect(melodyTrack.getInstrument()).toBe('acoustic_grand_piano');
|
||||
expect(melodyTrack.getVolume()).toBe(0.8);
|
||||
|
||||
const padTrack = tracks[1] as KGMidiTrack
|
||||
expect(padTrack.getName()).toBe('Pad Chord')
|
||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age')
|
||||
expect(padTrack.getVolume()).toBe(1)
|
||||
const padTrack = tracks[1] as KGMidiTrack;
|
||||
expect(padTrack.getName()).toBe('Pad Chord');
|
||||
expect(padTrack.getInstrument()).toBe('pad_1_new_age');
|
||||
expect(padTrack.getVolume()).toBe(1);
|
||||
|
||||
// Both tracks should be processable for ABC notation
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
const padRegion = padTrack.getRegions()[0] as KGMidiRegion;
|
||||
|
||||
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32)
|
||||
const padABC = convertRegionToABCNotation(padRegion, 0, 32)
|
||||
const melodyABC = convertRegionToABCNotation(melodyRegion, 0, 32);
|
||||
const padABC = convertRegionToABCNotation(padRegion, 0, 32);
|
||||
|
||||
expect(melodyABC).toContain('T:Melody Region 1')
|
||||
expect(padABC).toContain('T:Pad Chord Region 1')
|
||||
})
|
||||
expect(melodyABC).toContain('T:Melody Region 1');
|
||||
expect(padABC).toContain('T:Pad Chord Region 1');
|
||||
});
|
||||
|
||||
it('should verify complete deserialization hierarchy', () => {
|
||||
// Test that class-transformer properly restored the entire object hierarchy
|
||||
expect(joyProject).toBeInstanceOf(KGProject)
|
||||
expect(joyProject).toBeInstanceOf(KGProject);
|
||||
|
||||
const tracks = joyProject.getTracks()
|
||||
const tracks = joyProject.getTracks();
|
||||
tracks.forEach(track => {
|
||||
expect(track).toBeInstanceOf(KGMidiTrack)
|
||||
expect(track).toBeInstanceOf(KGMidiTrack);
|
||||
|
||||
const regions = track.getRegions()
|
||||
const regions = track.getRegions();
|
||||
regions.forEach(region => {
|
||||
expect(region).toBeInstanceOf(KGMidiRegion)
|
||||
expect(region).toBeInstanceOf(KGMidiRegion);
|
||||
|
||||
const notes = (region as KGMidiRegion).getNotes()
|
||||
const notes = (region as KGMidiRegion).getNotes();
|
||||
notes.forEach(note => {
|
||||
// Notes should have proper methods and properties
|
||||
expect(typeof note.getId()).toBe('string')
|
||||
expect(typeof note.getPitch()).toBe('number')
|
||||
expect(typeof note.getStartBeat()).toBe('number')
|
||||
expect(typeof note.getEndBeat()).toBe('number')
|
||||
expect(typeof note.getVelocity()).toBe('number')
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(typeof note.getId()).toBe('string');
|
||||
expect(typeof note.getPitch()).toBe('number');
|
||||
expect(typeof note.getStartBeat()).toBe('number');
|
||||
expect(typeof note.getEndBeat()).toBe('number');
|
||||
expect(typeof note.getVelocity()).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Verify type identifiers are preserved
|
||||
tracks.forEach(track => {
|
||||
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack')
|
||||
})
|
||||
})
|
||||
})
|
||||
expect((track as KGMidiTrack).getCurrentType()).toBe('KGMidiTrack');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with real project data', () => {
|
||||
it('should handle partial region conversion', () => {
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
|
||||
// Test converting only first 8 beats (2 bars)
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 8)
|
||||
const result = convertRegionToABCNotation(melodyRegion, 0, 8);
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('T:Melody Region 1')
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('T:Melody Region 1');
|
||||
// Should only contain the first 2 bars of music
|
||||
expect(result).not.toContain('D3/2 C1/2 C2') // This appears later in the song
|
||||
})
|
||||
expect(result).not.toContain('D3/2 C1/2 C2'); // This appears later in the song
|
||||
});
|
||||
|
||||
it('should handle mid-region start point', () => {
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion
|
||||
const melodyTrack = joyProject.getTracks()[0] as KGMidiTrack;
|
||||
const melodyRegion = melodyTrack.getRegions()[0] as KGMidiRegion;
|
||||
|
||||
// Test converting from beat 16 to 24 (second half of melody)
|
||||
const result = convertRegionToABCNotation(melodyRegion, 16, 24)
|
||||
const result = convertRegionToABCNotation(melodyRegion, 16, 24);
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result).toContain('T:Melody Region 1')
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toContain('T:Melody Region 1');
|
||||
// Should start from the second repetition
|
||||
const lines = result.split('\n')
|
||||
const musicLine = lines[lines.length - 1] // Last line contains the music
|
||||
expect(musicLine).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
const lines = result.split('\n');
|
||||
const musicLine = lines[lines.length - 1]; // Last line contains the music
|
||||
expect(musicLine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Mock implementation of KGAudioInterface for integration tests
|
||||
* Provides interface compatibility while avoiding actual audio operations
|
||||
*/
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const mockAudioInterface = {
|
||||
// Audio context management
|
||||
@@ -34,7 +34,7 @@ export const mockAudioInterface = {
|
||||
|
||||
// Singleton pattern
|
||||
getInstance: vi.fn().mockReturnThis(),
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the class constructor
|
||||
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface)
|
||||
export const mockKGAudioInterfaceClass = vi.fn(() => mockAudioInterface);
|
||||
@@ -2,54 +2,57 @@
|
||||
* Mock implementation of IndexedDB for integration tests
|
||||
* Provides in-memory storage that mimics IndexedDB interface
|
||||
*/
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// In-memory storage for tests
|
||||
const mockStorage = new Map<string, any>()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockStorage = new Map<string, any>();
|
||||
|
||||
export const mockIndexedDB = {
|
||||
openDB: vi.fn().mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
put: vi.fn().mockImplementation((storeName: string, data: any, key?: string) => {
|
||||
const actualKey = key || data.id || 'default'
|
||||
mockStorage.set(`${storeName}:${actualKey}`, data)
|
||||
return Promise.resolve(actualKey)
|
||||
const actualKey = key || data.id || 'default';
|
||||
mockStorage.set(`${storeName}:${actualKey}`, data);
|
||||
return Promise.resolve(actualKey);
|
||||
}),
|
||||
|
||||
get: vi.fn().mockImplementation((storeName: string, key: string) => {
|
||||
return Promise.resolve(mockStorage.get(`${storeName}:${key}`))
|
||||
return Promise.resolve(mockStorage.get(`${storeName}:${key}`));
|
||||
}),
|
||||
|
||||
getAll: vi.fn().mockImplementation((storeName: string) => {
|
||||
const results: any[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const results: any[] = [];
|
||||
for (const [key, value] of mockStorage.entries()) {
|
||||
if (key.startsWith(`${storeName}:`)) {
|
||||
results.push(value)
|
||||
results.push(value);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(results)
|
||||
return Promise.resolve(results);
|
||||
}),
|
||||
|
||||
delete: vi.fn().mockImplementation((storeName: string, key: string) => {
|
||||
mockStorage.delete(`${storeName}:${key}`)
|
||||
return Promise.resolve()
|
||||
mockStorage.delete(`${storeName}:${key}`);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
|
||||
clear: vi.fn().mockImplementation((storeName: string) => {
|
||||
for (const key of mockStorage.keys()) {
|
||||
if (key.startsWith(`${storeName}:`)) {
|
||||
mockStorage.delete(key)
|
||||
mockStorage.delete(key);
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
return Promise.resolve();
|
||||
}),
|
||||
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
});
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to clear mock storage between tests
|
||||
export const clearMockStorage = () => {
|
||||
mockStorage.clear()
|
||||
}
|
||||
mockStorage.clear();
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
* Mock implementation of Tone.js for integration tests
|
||||
* Provides interface compatibility while avoiding actual audio operations
|
||||
*/
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock Sampler class
|
||||
export const mockSampler = {
|
||||
@@ -16,7 +16,7 @@ export const mockSampler = {
|
||||
disconnect: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
get: vi.fn().mockReturnValue({}),
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Transport
|
||||
export const mockTransport = {
|
||||
@@ -30,7 +30,7 @@ export const mockTransport = {
|
||||
schedule: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Tone namespace
|
||||
export const mockTone = {
|
||||
@@ -55,4 +55,4 @@ export const mockTone = {
|
||||
state: 'running',
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}
|
||||
};
|
||||
+35
-13
@@ -1,4 +1,4 @@
|
||||
import { vi } from 'vitest'
|
||||
import { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mock implementation of Tone.js for testing
|
||||
@@ -18,14 +18,14 @@ export const MockSampler = vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
toDestination: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock Transport object
|
||||
export const MockTransport = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
position: '0:0:0',
|
||||
position: 0,
|
||||
bpm: {
|
||||
value: 120,
|
||||
rampTo: vi.fn()
|
||||
@@ -34,9 +34,22 @@ export const MockTransport = {
|
||||
state: 'stopped',
|
||||
scheduleOnce: vi.fn(),
|
||||
scheduleRepeat: vi.fn(),
|
||||
schedule: vi.fn().mockReturnValue(1),
|
||||
cancel: vi.fn(),
|
||||
clear: vi.fn()
|
||||
}
|
||||
,
|
||||
setLoopPoints: vi.fn(),
|
||||
loop: false,
|
||||
PPQ: 192,
|
||||
getTicksAtTime: vi.fn().mockImplementation((time: number) => time * 192)
|
||||
};
|
||||
|
||||
export const MockLoop = vi.fn().mockImplementation((callback: (time: number) => void, interval: string) => ({
|
||||
callback,
|
||||
interval,
|
||||
start: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock Destination
|
||||
export const MockDestination = {
|
||||
@@ -46,7 +59,7 @@ export const MockDestination = {
|
||||
mute: false,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
// Mock ToneAudioBuffer
|
||||
export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
||||
@@ -55,7 +68,7 @@ export const MockToneAudioBuffer = vi.fn().mockImplementation(() => ({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
load: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock Gain node
|
||||
export const MockGain = vi.fn().mockImplementation(() => ({
|
||||
@@ -68,7 +81,7 @@ export const MockGain = vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock Meter
|
||||
export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||
@@ -76,11 +89,12 @@ export const MockMeter = vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
}));
|
||||
|
||||
// Complete Tone.js mock
|
||||
export const ToneMock = {
|
||||
Sampler: MockSampler,
|
||||
Loop: MockLoop,
|
||||
Transport: MockTransport,
|
||||
Destination: MockDestination,
|
||||
ToneAudioBuffer: MockToneAudioBuffer,
|
||||
@@ -93,7 +107,14 @@ export const ToneMock = {
|
||||
state: 'running',
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
suspend: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
lookAhead: 0.05,
|
||||
setTimeout: vi.fn().mockImplementation((fn: () => void, timeoutSeconds: number) => {
|
||||
return window.setTimeout(fn, timeoutSeconds * 1000);
|
||||
}),
|
||||
clearTimeout: vi.fn().mockImplementation((id: number) => {
|
||||
window.clearTimeout(id);
|
||||
})
|
||||
}),
|
||||
|
||||
// Time utilities
|
||||
@@ -106,10 +127,11 @@ export const ToneMock = {
|
||||
Frequency: vi.fn().mockImplementation((freq) => ({
|
||||
toFrequency: vi.fn().mockReturnValue(parseFloat(freq) || 440),
|
||||
valueOf: vi.fn().mockReturnValue(parseFloat(freq) || 440)
|
||||
}))
|
||||
}
|
||||
})),
|
||||
now: vi.fn().mockImplementation(() => Date.now() / 1000)
|
||||
};
|
||||
|
||||
// Setup the global mock
|
||||
export const setupToneMocks = () => {
|
||||
vi.doMock('tone', () => ToneMock)
|
||||
}
|
||||
vi.doMock('tone', () => ToneMock);
|
||||
};
|
||||
|
||||
+18
-18
@@ -1,13 +1,13 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import 'reflect-metadata' // Required for class-transformer decorators
|
||||
import { beforeAll, afterEach, vi } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom';
|
||||
import 'reflect-metadata'; // Required for class-transformer decorators
|
||||
import { beforeAll, afterEach, vi } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// Import our custom Tone.js mocks
|
||||
import { setupToneMocks } from './mocks/tone'
|
||||
import { setupToneMocks } from './mocks/tone';
|
||||
|
||||
// Setup global mocks
|
||||
setupToneMocks()
|
||||
setupToneMocks();
|
||||
|
||||
// Mock KGCore globally to prevent store initialization issues
|
||||
vi.mock('../core/KGCore', () => ({
|
||||
@@ -24,22 +24,22 @@ vi.mock('../core/KGCore', () => ({
|
||||
executeCommand: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
// Global test setup for all unit tests
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Setup before all tests
|
||||
beforeAll(() => {
|
||||
// Mock console methods to reduce noise in tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Mock window.matchMedia (needed for some UI components)
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -54,16 +54,16 @@ beforeAll(() => {
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
});
|
||||
|
||||
// Mock ResizeObserver (might be needed for some components)
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock URL.createObjectURL (might be needed for file operations)
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url')
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
global.URL.createObjectURL = vi.fn(() => 'mocked-url');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
+27
-27
@@ -1,7 +1,7 @@
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote'
|
||||
import { KGProject } from '../../core/KGProject'
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack'
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion'
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
|
||||
/**
|
||||
* Test data factories for creating mock objects
|
||||
@@ -22,7 +22,7 @@ export const createMockMidiNote = (overrides: Partial<{
|
||||
pitch: 60, // Middle C
|
||||
velocity: 80,
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
|
||||
return new KGMidiNote(
|
||||
defaults.id,
|
||||
@@ -30,8 +30,8 @@ export const createMockMidiNote = (overrides: Partial<{
|
||||
defaults.endBeat,
|
||||
defaults.pitch,
|
||||
defaults.velocity
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const createMockMidiRegion = (overrides: Partial<{
|
||||
id: string
|
||||
@@ -50,7 +50,7 @@ export const createMockMidiRegion = (overrides: Partial<{
|
||||
startFromBeat: 0,
|
||||
length: 4,
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
|
||||
const region = new KGMidiRegion(
|
||||
defaults.id,
|
||||
@@ -59,15 +59,15 @@ export const createMockMidiRegion = (overrides: Partial<{
|
||||
defaults.name,
|
||||
defaults.startFromBeat,
|
||||
defaults.length
|
||||
)
|
||||
);
|
||||
|
||||
// Add notes if provided
|
||||
if (overrides.notes) {
|
||||
overrides.notes.forEach(note => region.addNote(note))
|
||||
overrides.notes.forEach(note => region.addNote(note));
|
||||
}
|
||||
|
||||
return region
|
||||
}
|
||||
return region;
|
||||
};
|
||||
|
||||
export const createMockMidiTrack = (overrides: Partial<{
|
||||
name: string
|
||||
@@ -82,22 +82,22 @@ export const createMockMidiTrack = (overrides: Partial<{
|
||||
instrument: 'acoustic_grand_piano' as const,
|
||||
volume: 0.8,
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
|
||||
const track = new KGMidiTrack(
|
||||
defaults.name,
|
||||
defaults.id,
|
||||
defaults.instrument as keyof typeof import('../../constants/generalMidiConstants').FLUIDR3_INSTRUMENT_MAP,
|
||||
defaults.volume
|
||||
)
|
||||
);
|
||||
|
||||
// Add regions if provided
|
||||
if (overrides.regions) {
|
||||
track.setRegions(overrides.regions)
|
||||
track.setRegions(overrides.regions);
|
||||
}
|
||||
|
||||
return track
|
||||
}
|
||||
return track;
|
||||
};
|
||||
|
||||
export const createMockProject = (overrides: Partial<{
|
||||
name: string
|
||||
@@ -111,7 +111,7 @@ export const createMockProject = (overrides: Partial<{
|
||||
timeSignature: { numerator: 4, denominator: 4 },
|
||||
tracks: [],
|
||||
...overrides
|
||||
}
|
||||
};
|
||||
|
||||
const project = new KGProject(
|
||||
defaults.name,
|
||||
@@ -126,34 +126,34 @@ export const createMockProject = (overrides: Partial<{
|
||||
1, // barWidthMultiplier
|
||||
defaults.tracks, // tracks
|
||||
5 // projectStructureVersion
|
||||
)
|
||||
);
|
||||
|
||||
return project
|
||||
}
|
||||
return project;
|
||||
};
|
||||
|
||||
// Common test scenarios
|
||||
export const createBasicProjectWithTrack = (): { project: KGProject; track: KGMidiTrack; region: KGMidiRegion } => {
|
||||
const notes = [
|
||||
createMockMidiNote({ pitch: 60, startBeat: 0, endBeat: 1 }),
|
||||
createMockMidiNote({ pitch: 64, startBeat: 1, endBeat: 2 }),
|
||||
]
|
||||
];
|
||||
|
||||
const region = createMockMidiRegion({
|
||||
id: 'region-1',
|
||||
trackId: 'track-1',
|
||||
notes
|
||||
})
|
||||
});
|
||||
|
||||
const track = createMockMidiTrack({
|
||||
id: 1,
|
||||
name: 'Track 1',
|
||||
regions: [region]
|
||||
})
|
||||
});
|
||||
|
||||
const project = createMockProject({
|
||||
name: 'Basic Test Project',
|
||||
tracks: [track]
|
||||
})
|
||||
});
|
||||
|
||||
return { project, track, region }
|
||||
}
|
||||
return { project, track, region };
|
||||
};
|
||||
@@ -2,43 +2,43 @@
|
||||
* Integration test setup utilities
|
||||
* Common setup and teardown for integration tests
|
||||
*/
|
||||
import { beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mockAudioInterface } from '../mocks/audio-interface'
|
||||
import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db'
|
||||
import { mockTone } from '../mocks/tone-js'
|
||||
import { KGCore } from '../../core/KGCore'
|
||||
import { KGProject } from '../../core/KGProject'
|
||||
import { beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mockAudioInterface } from '../mocks/audio-interface';
|
||||
import { mockIndexedDB, clearMockStorage } from '../mocks/indexed-db';
|
||||
import { mockTone } from '../mocks/tone-js';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
|
||||
// Initialize KGCore with a default project at module load time
|
||||
// This prevents errors when projectStore module initializes and tries to access currentProject
|
||||
// This runs synchronously when the module is imported, before any tests
|
||||
const initializeKGCore = async () => {
|
||||
const defaultProject = new KGProject('Test Setup Project')
|
||||
const core = KGCore.instance()
|
||||
await core.initialize()
|
||||
core.setCurrentProject(defaultProject)
|
||||
}
|
||||
const defaultProject = new KGProject('Test Setup Project');
|
||||
const core = KGCore.instance();
|
||||
await core.initialize();
|
||||
core.setCurrentProject(defaultProject);
|
||||
};
|
||||
|
||||
// Run initialization immediately at module load
|
||||
await initializeKGCore()
|
||||
await initializeKGCore();
|
||||
|
||||
// Global setup for integration tests
|
||||
beforeEach(() => {
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear mock storage
|
||||
clearMockStorage()
|
||||
clearMockStorage();
|
||||
|
||||
// Mock external dependencies while keeping internal components real
|
||||
vi.doMock('../../audio/KGAudioInterface', () => ({
|
||||
KGAudioInterface: mockAudioInterface,
|
||||
default: mockAudioInterface,
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.doMock('idb', () => mockIndexedDB)
|
||||
vi.doMock('idb', () => mockIndexedDB);
|
||||
|
||||
vi.doMock('tone', () => mockTone)
|
||||
vi.doMock('tone', () => mockTone);
|
||||
|
||||
// Mock browser APIs that might be used
|
||||
Object.defineProperty(window, 'AudioContext', {
|
||||
@@ -47,19 +47,19 @@ beforeEach(() => {
|
||||
state: 'running',
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
})
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'webkitAudioContext', {
|
||||
writable: true,
|
||||
value: window.AudioContext,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
vi.restoreAllMocks()
|
||||
clearMockStorage()
|
||||
})
|
||||
vi.restoreAllMocks();
|
||||
clearMockStorage();
|
||||
});
|
||||
|
||||
// Helper functions for integration tests
|
||||
export const createMockProject = () => {
|
||||
@@ -73,8 +73,8 @@ export const createMockProject = () => {
|
||||
keySignature: 'C major',
|
||||
maxBars: 32,
|
||||
tracks: [],
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockTrack = (name = 'Test Track') => {
|
||||
return {
|
||||
@@ -83,8 +83,8 @@ export const createMockTrack = (name = 'Test Track') => {
|
||||
instrument: 'acoustic_grand_piano',
|
||||
volume: 0.8,
|
||||
regions: [],
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockRegion = (name = 'Test Region') => {
|
||||
return {
|
||||
@@ -93,8 +93,8 @@ export const createMockRegion = (name = 'Test Region') => {
|
||||
startBeat: 0,
|
||||
endBeat: 4,
|
||||
notes: [],
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
|
||||
return {
|
||||
@@ -103,5 +103,5 @@ export const createMockNote = (pitch = 60, startBeat = 0, endBeat = 1) => {
|
||||
startBeat,
|
||||
endBeat,
|
||||
velocity: 100,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { render, type RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react';
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
|
||||
// Custom render function that includes any providers your app needs
|
||||
// This can be extended later with Zustand store providers, etc.
|
||||
@@ -13,10 +13,10 @@ const customRender = (
|
||||
// If you need to wrap components with providers (like Zustand store),
|
||||
// you can create an AllTheProviders wrapper here
|
||||
|
||||
return render(ui, options)
|
||||
}
|
||||
return render(ui, options);
|
||||
};
|
||||
|
||||
// Re-export everything from testing-library/react
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
export * from '@testing-library/react';
|
||||
export { customRender as render };
|
||||
@@ -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 {
|
||||
beatsToBar,
|
||||
pitchToNoteNameString,
|
||||
pitchToNoteName,
|
||||
pianoRollIndexToPitch,
|
||||
noteNameToPitch
|
||||
} from './midiUtil'
|
||||
} from './midiUtil';
|
||||
|
||||
describe('midiUtil', () => {
|
||||
describe('beatsToBar', () => {
|
||||
it('should convert beats to bar position object', () => {
|
||||
const result1 = beatsToBar(0, { numerator: 4, denominator: 4 })
|
||||
expect(result1.bar).toBe(0)
|
||||
expect(result1.beatInBar).toBe(0)
|
||||
const result1 = beatsToBar(0, { numerator: 4, denominator: 4 });
|
||||
expect(result1.bar).toBe(0);
|
||||
expect(result1.beatInBar).toBe(0);
|
||||
|
||||
const result2 = beatsToBar(4, { numerator: 4, denominator: 4 })
|
||||
expect(result2.bar).toBe(1)
|
||||
expect(result2.beatInBar).toBe(0)
|
||||
const result2 = beatsToBar(4, { numerator: 4, denominator: 4 });
|
||||
expect(result2.bar).toBe(1);
|
||||
expect(result2.beatInBar).toBe(0);
|
||||
|
||||
const result3 = beatsToBar(8, { numerator: 4, denominator: 4 })
|
||||
expect(result3.bar).toBe(2)
|
||||
expect(result3.beatInBar).toBe(0)
|
||||
})
|
||||
const result3 = beatsToBar(8, { numerator: 4, denominator: 4 });
|
||||
expect(result3.bar).toBe(2);
|
||||
expect(result3.beatInBar).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle different time signatures', () => {
|
||||
const result1 = beatsToBar(0, { numerator: 3, denominator: 4 })
|
||||
expect(result1.bar).toBe(0)
|
||||
expect(result1.beatInBar).toBe(0)
|
||||
const result1 = beatsToBar(0, { numerator: 3, denominator: 4 });
|
||||
expect(result1.bar).toBe(0);
|
||||
expect(result1.beatInBar).toBe(0);
|
||||
|
||||
const result2 = beatsToBar(3, { numerator: 3, denominator: 4 })
|
||||
expect(result2.bar).toBe(1)
|
||||
expect(result2.beatInBar).toBe(0)
|
||||
})
|
||||
const result2 = beatsToBar(3, { numerator: 3, denominator: 4 });
|
||||
expect(result2.bar).toBe(1);
|
||||
expect(result2.beatInBar).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle fractional beats', () => {
|
||||
const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 })
|
||||
expect(result1.bar).toBe(0)
|
||||
expect(result1.beatInBar).toBe(2.5)
|
||||
const result1 = beatsToBar(2.5, { numerator: 4, denominator: 4 });
|
||||
expect(result1.bar).toBe(0);
|
||||
expect(result1.beatInBar).toBe(2.5);
|
||||
|
||||
const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 })
|
||||
expect(result2.bar).toBe(1)
|
||||
expect(result2.beatInBar).toBe(0.5)
|
||||
})
|
||||
})
|
||||
const result2 = beatsToBar(4.5, { numerator: 4, denominator: 4 });
|
||||
expect(result2.bar).toBe(1);
|
||||
expect(result2.beatInBar).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitchToNoteNameString', () => {
|
||||
it('should convert MIDI pitch to note name with octave', () => {
|
||||
expect(pitchToNoteNameString(60)).toBe('C4') // Middle C
|
||||
expect(pitchToNoteNameString(61)).toBe('C#4') // C# above middle C
|
||||
expect(pitchToNoteNameString(59)).toBe('B3') // B below middle C
|
||||
expect(pitchToNoteNameString(72)).toBe('C5') // C one octave above middle C
|
||||
expect(pitchToNoteNameString(48)).toBe('C3') // C one octave below middle C
|
||||
})
|
||||
expect(pitchToNoteNameString(60)).toBe('C4'); // Middle C
|
||||
expect(pitchToNoteNameString(61)).toBe('C#4'); // C# above middle C
|
||||
expect(pitchToNoteNameString(59)).toBe('B3'); // B below middle C
|
||||
expect(pitchToNoteNameString(72)).toBe('C5'); // C one octave above middle C
|
||||
expect(pitchToNoteNameString(48)).toBe('C3'); // C one octave below middle C
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(pitchToNoteNameString(0)).toBe('C-1') // Lowest MIDI note
|
||||
expect(pitchToNoteNameString(127)).toBe('G9') // Highest MIDI note
|
||||
})
|
||||
expect(pitchToNoteNameString(0)).toBe('C-1'); // Lowest MIDI note
|
||||
expect(pitchToNoteNameString(127)).toBe('G9'); // Highest MIDI note
|
||||
});
|
||||
|
||||
it('should handle all chromatic notes', () => {
|
||||
const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4']
|
||||
const expectedNotes = ['C4', 'C#4', 'D4', 'D#4', 'E4', 'F4', 'F#4', 'G4', 'G#4', 'A4', 'A#4', 'B4'];
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i])
|
||||
expect(pitchToNoteNameString(60 + i)).toBe(expectedNotes[i]);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitchToNoteName', () => {
|
||||
it('should convert pitch to note name object', () => {
|
||||
const result60 = pitchToNoteName(60) // Middle C
|
||||
expect(result60.note).toBe('C')
|
||||
expect(result60.octave).toBe(4)
|
||||
const result60 = pitchToNoteName(60); // Middle C
|
||||
expect(result60.note).toBe('C');
|
||||
expect(result60.octave).toBe(4);
|
||||
|
||||
const result61 = pitchToNoteName(61) // C#
|
||||
expect(result61.note).toBe('C#')
|
||||
expect(result61.octave).toBe(4)
|
||||
})
|
||||
const result61 = pitchToNoteName(61); // C#
|
||||
expect(result61.note).toBe('C#');
|
||||
expect(result61.octave).toBe(4);
|
||||
});
|
||||
|
||||
it('should wrap around for different octaves', () => {
|
||||
const result60 = pitchToNoteName(60)
|
||||
const result72 = pitchToNoteName(72)
|
||||
const result84 = pitchToNoteName(84)
|
||||
const result60 = pitchToNoteName(60);
|
||||
const result72 = pitchToNoteName(72);
|
||||
const result84 = pitchToNoteName(84);
|
||||
|
||||
expect(result60.note).toBe('C')
|
||||
expect(result72.note).toBe('C')
|
||||
expect(result84.note).toBe('C')
|
||||
expect(result60.note).toBe('C');
|
||||
expect(result72.note).toBe('C');
|
||||
expect(result84.note).toBe('C');
|
||||
|
||||
expect(result60.octave).toBe(4)
|
||||
expect(result72.octave).toBe(5)
|
||||
expect(result84.octave).toBe(6)
|
||||
})
|
||||
})
|
||||
expect(result60.octave).toBe(4);
|
||||
expect(result72.octave).toBe(5);
|
||||
expect(result84.octave).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pianoRollIndexToPitch', () => {
|
||||
it('should convert piano roll row index to MIDI pitch', () => {
|
||||
// This function likely maps visual rows to MIDI pitches
|
||||
// The exact mapping depends on your implementation
|
||||
const result = pianoRollIndexToPitch(10)
|
||||
expect(typeof result).toBe('number')
|
||||
expect(result).toBeGreaterThanOrEqual(0)
|
||||
expect(result).toBeLessThanOrEqual(127)
|
||||
})
|
||||
const result = pianoRollIndexToPitch(10);
|
||||
expect(typeof result).toBe('number');
|
||||
expect(result).toBeGreaterThanOrEqual(0);
|
||||
expect(result).toBeLessThanOrEqual(127);
|
||||
});
|
||||
|
||||
it('should return different pitches for different indices', () => {
|
||||
const pitch1 = pianoRollIndexToPitch(0)
|
||||
const pitch2 = pianoRollIndexToPitch(1)
|
||||
expect(pitch1).not.toBe(pitch2)
|
||||
})
|
||||
})
|
||||
const pitch1 = pianoRollIndexToPitch(0);
|
||||
const pitch2 = pianoRollIndexToPitch(1);
|
||||
expect(pitch1).not.toBe(pitch2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteNameToPitch', () => {
|
||||
it('should convert note names to MIDI pitch', () => {
|
||||
expect(noteNameToPitch('C4')).toBe(60) // Middle C
|
||||
expect(noteNameToPitch('C#4')).toBe(61) // C# above middle C
|
||||
expect(noteNameToPitch('D4')).toBe(62) // D above middle C
|
||||
})
|
||||
expect(noteNameToPitch('C4')).toBe(60); // Middle C
|
||||
expect(noteNameToPitch('C#4')).toBe(61); // C# above middle C
|
||||
expect(noteNameToPitch('D4')).toBe(62); // D above middle C
|
||||
});
|
||||
|
||||
it('should handle different octaves', () => {
|
||||
expect(noteNameToPitch('C3')).toBe(48) // C below middle C
|
||||
expect(noteNameToPitch('C5')).toBe(72) // C above middle C
|
||||
})
|
||||
expect(noteNameToPitch('C3')).toBe(48); // C below middle C
|
||||
expect(noteNameToPitch('C5')).toBe(72); // C above middle C
|
||||
});
|
||||
|
||||
it('should handle sharps', () => {
|
||||
expect(noteNameToPitch('C#4')).toBe(61)
|
||||
expect(noteNameToPitch('F#4')).toBe(66)
|
||||
expect(noteNameToPitch('G#4')).toBe(68)
|
||||
})
|
||||
expect(noteNameToPitch('C#4')).toBe(61);
|
||||
expect(noteNameToPitch('F#4')).toBe(66);
|
||||
expect(noteNameToPitch('G#4')).toBe(68);
|
||||
});
|
||||
|
||||
it('should handle invalid note names', () => {
|
||||
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4') // Flats not supported
|
||||
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4') // Invalid note
|
||||
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C') // Missing octave
|
||||
})
|
||||
})
|
||||
expect(() => noteNameToPitch('Db4')).toThrow('Invalid note name: Db4'); // Flats not supported
|
||||
expect(() => noteNameToPitch('H4')).toThrow('Invalid note name: H4'); // Invalid note
|
||||
expect(() => noteNameToPitch('C')).toThrow('Invalid note name: C'); // Missing octave
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle negative values gracefully', () => {
|
||||
expect(() => pitchToNoteNameString(-1)).not.toThrow()
|
||||
expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow()
|
||||
})
|
||||
expect(() => pitchToNoteNameString(-1)).not.toThrow();
|
||||
expect(() => beatsToBar(-1, { numerator: 4, denominator: 4 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle very large values', () => {
|
||||
expect(() => pitchToNoteNameString(200)).not.toThrow()
|
||||
expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow()
|
||||
})
|
||||
expect(() => pitchToNoteNameString(200)).not.toThrow();
|
||||
expect(() => beatsToBar(1000, { numerator: 4, denominator: 4 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
expect(pitchToNoteNameString(0)).toBeDefined()
|
||||
expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined()
|
||||
})
|
||||
})
|
||||
expect(pitchToNoteNameString(0)).toBeDefined();
|
||||
expect(beatsToBar(0, { numerator: 4, denominator: 4 })).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mathematical consistency', () => {
|
||||
it('should maintain pitch relationships', () => {
|
||||
// One octave = 12 semitones - note names should be the same
|
||||
const baseNote = pitchToNoteName(60)
|
||||
const octaveNote = pitchToNoteName(72)
|
||||
expect(baseNote.note).toBe(octaveNote.note) // Both should be 'C'
|
||||
expect(octaveNote.octave).toBe(baseNote.octave + 1) // Octave should be one higher
|
||||
})
|
||||
const baseNote = pitchToNoteName(60);
|
||||
const octaveNote = pitchToNoteName(72);
|
||||
expect(baseNote.note).toBe(octaveNote.note); // Both should be 'C'
|
||||
expect(octaveNote.octave).toBe(baseNote.octave + 1); // Octave should be one higher
|
||||
});
|
||||
|
||||
it('should maintain beat-to-bar relationships', () => {
|
||||
const timeSignature = { numerator: 4, denominator: 4 }
|
||||
const timeSignature = { numerator: 4, denominator: 4 };
|
||||
|
||||
// Should increment bar by 1 for each complete measure
|
||||
for (let beat = 0; beat < 20; beat += 4) {
|
||||
const expectedBar = Math.floor(beat / 4)
|
||||
const result = beatsToBar(beat, timeSignature)
|
||||
expect(result.bar).toBe(expectedBar)
|
||||
expect(result.beatInBar).toBe(0) // Should be at start of bar
|
||||
const expectedBar = Math.floor(beat / 4);
|
||||
const result = beatsToBar(beat, timeSignature);
|
||||
expect(result.bar).toBe(expectedBar);
|
||||
expect(result.beatInBar).toBe(0); // Should be at start of bar
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('should maintain note name to pitch conversion consistency', () => {
|
||||
// Converting pitch to note name and back should be consistent
|
||||
const originalPitch = 60
|
||||
const noteObj = pitchToNoteName(originalPitch)
|
||||
const noteName = `${noteObj.note}${noteObj.octave}`
|
||||
const convertedPitch = noteNameToPitch(noteName)
|
||||
const originalPitch = 60;
|
||||
const noteObj = pitchToNoteName(originalPitch);
|
||||
const noteName = `${noteObj.note}${noteObj.octave}`;
|
||||
const convertedPitch = noteNameToPitch(noteName);
|
||||
|
||||
expect(convertedPitch).toBe(originalPitch)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(convertedPitch).toBe(originalPitch);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KGProjectStorage } from '../core/io/KGProjectStorage';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import { RESERVED_PROJECT_NAME } from './projectNameUtil';
|
||||
import { showAlert } from '../components/common/DialogProvider';
|
||||
import { showAlert } from './dialogUtil';
|
||||
|
||||
/**
|
||||
* Save project utility function.
|
||||
|
||||
+292
-292
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
getRootNoteFromKeySignature,
|
||||
noteNameToPitchClass,
|
||||
@@ -9,208 +9,208 @@ import {
|
||||
getMatchingChordsForPitch,
|
||||
generatePianoGridBackground,
|
||||
validateFunctionalChordsJSON
|
||||
} from './scaleUtil'
|
||||
import { KGCore } from '../core/KGCore'
|
||||
import type { KeySignature } from '../core/KGProject'
|
||||
import functionalChordsData from '../../public/resources/modes/functional_chords.json'
|
||||
} from './scaleUtil';
|
||||
import { KGCore } from '../core/KGCore';
|
||||
import type { KeySignature } from '../core/KGProject';
|
||||
import functionalChordsData from '../../public/resources/modes/functional_chords.json';
|
||||
|
||||
describe('scaleUtil', () => {
|
||||
// Setup: Mock KGCore with real chord data
|
||||
beforeEach(() => {
|
||||
// Use real functional chords data from JSON file (includes name, steps, and chord data)
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData
|
||||
})
|
||||
KGCore.FUNCTIONAL_CHORDS_DATA = functionalChordsData;
|
||||
});
|
||||
|
||||
describe('getRootNoteFromKeySignature', () => {
|
||||
it('should extract root note from C major', () => {
|
||||
expect(getRootNoteFromKeySignature('C major')).toBe('C')
|
||||
})
|
||||
expect(getRootNoteFromKeySignature('C major')).toBe('C');
|
||||
});
|
||||
|
||||
it('should extract root note from F# minor', () => {
|
||||
expect(getRootNoteFromKeySignature('F# minor')).toBe('F#')
|
||||
})
|
||||
expect(getRootNoteFromKeySignature('F# minor')).toBe('F#');
|
||||
});
|
||||
|
||||
it('should extract root note from Bb major', () => {
|
||||
expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb')
|
||||
})
|
||||
expect(getRootNoteFromKeySignature('Bb major')).toBe('Bb');
|
||||
});
|
||||
|
||||
it('should handle Db major', () => {
|
||||
expect(getRootNoteFromKeySignature('Db major')).toBe('Db')
|
||||
})
|
||||
expect(getRootNoteFromKeySignature('Db major')).toBe('Db');
|
||||
});
|
||||
|
||||
it('should default to C for invalid format', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C')
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(getRootNoteFromKeySignature('Invalid' as KeySignature)).toBe('C');
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid key signature format'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('noteNameToPitchClass', () => {
|
||||
it('should convert C to 0', () => {
|
||||
expect(noteNameToPitchClass('C')).toBe(0)
|
||||
})
|
||||
expect(noteNameToPitchClass('C')).toBe(0);
|
||||
});
|
||||
|
||||
it('should convert C# to 1', () => {
|
||||
expect(noteNameToPitchClass('C#')).toBe(1)
|
||||
})
|
||||
expect(noteNameToPitchClass('C#')).toBe(1);
|
||||
});
|
||||
|
||||
it('should convert Db to 1', () => {
|
||||
expect(noteNameToPitchClass('Db')).toBe(1)
|
||||
})
|
||||
expect(noteNameToPitchClass('Db')).toBe(1);
|
||||
});
|
||||
|
||||
it('should convert D to 2', () => {
|
||||
expect(noteNameToPitchClass('D')).toBe(2)
|
||||
})
|
||||
expect(noteNameToPitchClass('D')).toBe(2);
|
||||
});
|
||||
|
||||
it('should convert E to 4', () => {
|
||||
expect(noteNameToPitchClass('E')).toBe(4)
|
||||
})
|
||||
expect(noteNameToPitchClass('E')).toBe(4);
|
||||
});
|
||||
|
||||
it('should convert F to 5', () => {
|
||||
expect(noteNameToPitchClass('F')).toBe(5)
|
||||
})
|
||||
expect(noteNameToPitchClass('F')).toBe(5);
|
||||
});
|
||||
|
||||
it('should convert F# to 6', () => {
|
||||
expect(noteNameToPitchClass('F#')).toBe(6)
|
||||
})
|
||||
expect(noteNameToPitchClass('F#')).toBe(6);
|
||||
});
|
||||
|
||||
it('should convert G to 7', () => {
|
||||
expect(noteNameToPitchClass('G')).toBe(7)
|
||||
})
|
||||
expect(noteNameToPitchClass('G')).toBe(7);
|
||||
});
|
||||
|
||||
it('should convert A to 9', () => {
|
||||
expect(noteNameToPitchClass('A')).toBe(9)
|
||||
})
|
||||
expect(noteNameToPitchClass('A')).toBe(9);
|
||||
});
|
||||
|
||||
it('should convert Bb to 10', () => {
|
||||
expect(noteNameToPitchClass('Bb')).toBe(10)
|
||||
})
|
||||
expect(noteNameToPitchClass('Bb')).toBe(10);
|
||||
});
|
||||
|
||||
it('should convert B to 11', () => {
|
||||
expect(noteNameToPitchClass('B')).toBe(11)
|
||||
})
|
||||
expect(noteNameToPitchClass('B')).toBe(11);
|
||||
});
|
||||
|
||||
it('should default to 0 for invalid note', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(noteNameToPitchClass('X')).toBe(0)
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(noteNameToPitchClass('X')).toBe(0);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid note name'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModeSteps', () => {
|
||||
it('should return ionian steps', () => {
|
||||
expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1])
|
||||
})
|
||||
expect(getModeSteps('ionian')).toEqual([2, 2, 1, 2, 2, 2, 1]);
|
||||
});
|
||||
|
||||
it('should return dorian steps', () => {
|
||||
expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2])
|
||||
})
|
||||
expect(getModeSteps('dorian')).toEqual([2, 1, 2, 2, 2, 1, 2]);
|
||||
});
|
||||
|
||||
it('should default to ionian for invalid mode', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1])
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(getModeSteps('invalid')).toEqual([2, 2, 1, 2, 2, 2, 1]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Mode not found'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScalePitchClasses', () => {
|
||||
it('should return C major scale pitch classes', () => {
|
||||
const steps = [2, 2, 1, 2, 2, 2, 1]
|
||||
const result = getScalePitchClasses('C', steps)
|
||||
expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]) // C D E F G A B
|
||||
})
|
||||
const steps = [2, 2, 1, 2, 2, 2, 1];
|
||||
const result = getScalePitchClasses('C', steps);
|
||||
expect(result).toEqual([0, 2, 4, 5, 7, 9, 11]); // C D E F G A B
|
||||
});
|
||||
|
||||
it('should return D major scale pitch classes', () => {
|
||||
const steps = [2, 2, 1, 2, 2, 2, 1]
|
||||
const result = getScalePitchClasses('D', steps)
|
||||
expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]) // D E F# G A B C#
|
||||
})
|
||||
const steps = [2, 2, 1, 2, 2, 2, 1];
|
||||
const result = getScalePitchClasses('D', steps);
|
||||
expect(result).toEqual([2, 4, 6, 7, 9, 11, 1]); // D E F# G A B C#
|
||||
});
|
||||
|
||||
it('should return F# dorian scale pitch classes', () => {
|
||||
const steps = [2, 1, 2, 2, 2, 1, 2]
|
||||
const result = getScalePitchClasses('F#', steps)
|
||||
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]) // F# G# A B C# D# E
|
||||
})
|
||||
})
|
||||
const steps = [2, 1, 2, 2, 2, 1, 2];
|
||||
const result = getScalePitchClasses('F#', steps);
|
||||
expect(result).toEqual([6, 8, 9, 11, 1, 3, 4]); // F# G# A B C# D# E
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSuitableChords', () => {
|
||||
it('should return tonic chords for C major ionian', () => {
|
||||
const result = getSuitableChords('C major', 'ionian', 'T')
|
||||
expect(result).toHaveProperty('I')
|
||||
expect(result).toHaveProperty('vi')
|
||||
expect(result).toHaveProperty('iii')
|
||||
expect(result['I']).toEqual(['C', 'E', 'G'])
|
||||
expect(result['vi']).toEqual(['A', 'C', 'E'])
|
||||
})
|
||||
const result = getSuitableChords('C major', 'ionian', 'T');
|
||||
expect(result).toHaveProperty('I');
|
||||
expect(result).toHaveProperty('vi');
|
||||
expect(result).toHaveProperty('iii');
|
||||
expect(result['I']).toEqual(['C', 'E', 'G']);
|
||||
expect(result['vi']).toEqual(['A', 'C', 'E']);
|
||||
});
|
||||
|
||||
it('should return subdominant chords for C major ionian', () => {
|
||||
const result = getSuitableChords('C major', 'ionian', 'S')
|
||||
expect(result).toHaveProperty('IV')
|
||||
expect(result).toHaveProperty('ii')
|
||||
expect(result['IV']).toEqual(['F', 'A', 'C'])
|
||||
expect(result['ii']).toEqual(['D', 'F', 'A'])
|
||||
})
|
||||
const result = getSuitableChords('C major', 'ionian', 'S');
|
||||
expect(result).toHaveProperty('IV');
|
||||
expect(result).toHaveProperty('ii');
|
||||
expect(result['IV']).toEqual(['F', 'A', 'C']);
|
||||
expect(result['ii']).toEqual(['D', 'F', 'A']);
|
||||
});
|
||||
|
||||
it('should return dominant chords for C major ionian', () => {
|
||||
const result = getSuitableChords('C major', 'ionian', 'D')
|
||||
expect(result).toHaveProperty('V')
|
||||
expect(result).toHaveProperty('V7')
|
||||
expect(result['V']).toEqual(['G', 'B', 'D'])
|
||||
expect(result['V7']).toEqual(['G', 'B', 'D', 'F'])
|
||||
})
|
||||
const result = getSuitableChords('C major', 'ionian', 'D');
|
||||
expect(result).toHaveProperty('V');
|
||||
expect(result).toHaveProperty('V7');
|
||||
expect(result['V']).toEqual(['G', 'B', 'D']);
|
||||
expect(result['V7']).toEqual(['G', 'B', 'D', 'F']);
|
||||
});
|
||||
|
||||
it('should transpose chords for D major ionian', () => {
|
||||
const result = getSuitableChords('D major', 'ionian', 'T')
|
||||
expect(result['I']).toEqual(['D', 'F#', 'A'])
|
||||
expect(result['vi']).toEqual(['B', 'D', 'F#'])
|
||||
})
|
||||
const result = getSuitableChords('D major', 'ionian', 'T');
|
||||
expect(result['I']).toEqual(['D', 'F#', 'A']);
|
||||
expect(result['vi']).toEqual(['B', 'D', 'F#']);
|
||||
});
|
||||
|
||||
it('should transpose chords for F# major ionian', () => {
|
||||
const result = getSuitableChords('F# major', 'ionian', 'T')
|
||||
expect(result['I']).toEqual(['F#', 'A#', 'C#'])
|
||||
})
|
||||
const result = getSuitableChords('F# major', 'ionian', 'T');
|
||||
expect(result['I']).toEqual(['F#', 'A#', 'C#']);
|
||||
});
|
||||
|
||||
it('should return empty object for invalid mode', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = getSuitableChords('C major', 'invalid', 'T')
|
||||
expect(result).toEqual({})
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = getSuitableChords('C major', 'invalid', 'T');
|
||||
expect(result).toEqual({});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No functional chords found'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChordNotesInKey', () => {
|
||||
it('should return I chord notes in C major ionian', () => {
|
||||
const result = getChordNotesInKey('I', 'C major', 'ionian')
|
||||
expect(result).toEqual(['C', 'E', 'G'])
|
||||
})
|
||||
const result = getChordNotesInKey('I', 'C major', 'ionian');
|
||||
expect(result).toEqual(['C', 'E', 'G']);
|
||||
});
|
||||
|
||||
it('should return V7 chord notes in C major ionian', () => {
|
||||
const result = getChordNotesInKey('V7', 'C major', 'ionian')
|
||||
expect(result).toEqual(['G', 'B', 'D', 'F'])
|
||||
})
|
||||
const result = getChordNotesInKey('V7', 'C major', 'ionian');
|
||||
expect(result).toEqual(['G', 'B', 'D', 'F']);
|
||||
});
|
||||
|
||||
it('should transpose to D major', () => {
|
||||
const result = getChordNotesInKey('I', 'D major', 'ionian')
|
||||
expect(result).toEqual(['D', 'F#', 'A'])
|
||||
})
|
||||
const result = getChordNotesInKey('I', 'D major', 'ionian');
|
||||
expect(result).toEqual(['D', 'F#', 'A']);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid chord symbol', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = getChordNotesInKey('invalid', 'C major', 'ionian')
|
||||
expect(result).toEqual([])
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'))
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = getChordNotesInKey('invalid', 'C major', 'ionian');
|
||||
expect(result).toEqual([]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Chord symbol not found'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMatchingChordsForPitch', () => {
|
||||
describe('valid inputs', () => {
|
||||
it('should return matching chords for C (pitch 60) in C major ionian tonic', () => {
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
|
||||
// C major ionian T chords from JSON: I ["C","E","G"], vi ["A","C","E"], iii ["E","G","B"], I⁶ ["E","G","C"]
|
||||
// Pitch 60 = C (pitch class 0)
|
||||
@@ -222,11 +222,11 @@ describe('scaleUtil', () => {
|
||||
[0, 4, 7], // I chord (C-E-G): C is root
|
||||
[-3, 0, 4], // vi chord (A-C-E): C is 2nd, offset applied
|
||||
[-8, -5, 0] // I⁶ chord (E-G-C): C is 3rd, offset applied
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return matching chords for E (pitch 64) in C major ionian tonic', () => {
|
||||
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
|
||||
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
|
||||
|
||||
// Pitch 64 = E (pitch class 4)
|
||||
// Expected matches prioritized by position:
|
||||
@@ -239,19 +239,19 @@ describe('scaleUtil', () => {
|
||||
[4, 7, 12], // I⁶ chord (E-G-C): E is root
|
||||
[0, 4, 7], // I chord (C-E-G): E is 2nd
|
||||
[-3, 0, 4] // vi chord (A-C-E): E is 3rd, offset applied
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('should prioritize root matches over other positions', () => {
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T') // C
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T'); // C
|
||||
// First chord should have C as root (position 0)
|
||||
if (result.length > 0) {
|
||||
expect(result[0][0] % 12).toBe(0) // First note of first chord should be C
|
||||
expect(result[0][0] % 12).toBe(0); // First note of first chord should be C
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('should return matching chords for F (pitch 65) in C major ionian subdominant', () => {
|
||||
const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S') // F
|
||||
const result = getMatchingChordsForPitch(65, 'C major', 'ionian', 'S'); // F
|
||||
|
||||
// Pitch 65 = F (pitch class 5)
|
||||
// C major ionian S chords from JSON: IV ["F","A","C"], ii ["D","F","A"], vi ["A","C","E"], IV⁶ ["A","C","F"]
|
||||
@@ -263,11 +263,11 @@ describe('scaleUtil', () => {
|
||||
[5, 9, 12], // IV chord (F-A-C): F is root, offset applied
|
||||
[2, 5, 9], // ii chord (D-F-A): F is 2nd
|
||||
[-3, 0, 5] // IV⁶ chord (A-C-F): F is 3rd, offset applied
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return matching chords for G (pitch 67) in C major ionian dominant', () => {
|
||||
const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D') // G
|
||||
const result = getMatchingChordsForPitch(67, 'C major', 'ionian', 'D'); // G
|
||||
|
||||
// Pitch 67 = G (pitch class 7)
|
||||
// C major ionian D chords from JSON: V ["G","B","D"], V7 ["G","B","D","F"], vii° ["B","D","F"], ♭II ["Db","F","Ab"]
|
||||
@@ -277,122 +277,122 @@ describe('scaleUtil', () => {
|
||||
expect(result).toEqual([
|
||||
[7, 11, 14], // V chord (G-B-D): G is root, offset applied
|
||||
[7, 11, 14, 17] // V7 chord (G-B-D-F): G is root, offset applied
|
||||
])
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work with different modes (dorian)', () => {
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'dorian', 'T');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should transpose correctly for D major', () => {
|
||||
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T') // D
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result.some(chord => chord.includes(2))).toBe(true) // Contains D (pitch class 2)
|
||||
})
|
||||
const result = getMatchingChordsForPitch(62, 'D major', 'ionian', 'T'); // D
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result.some(chord => chord.includes(2))).toBe(true); // Contains D (pitch class 2)
|
||||
});
|
||||
|
||||
it('should handle all MIDI pitch ranges (low)', () => {
|
||||
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T') // C1
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
const result = getMatchingChordsForPitch(24, 'C major', 'ionian', 'T'); // C1
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle all MIDI pitch ranges (high)', () => {
|
||||
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T') // C8
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
const result = getMatchingChordsForPitch(108, 'C major', 'ionian', 'T'); // C8
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array for invalid mode', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T')
|
||||
expect(result).toEqual([])
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'invalid', 'T');
|
||||
expect(result).toEqual([]);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle pitch class calculations correctly', () => {
|
||||
// Test that pitch 60 (C4) and pitch 72 (C5) both match C chords
|
||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
|
||||
expect(result1.length).toBe(result2.length) // Same chords match
|
||||
})
|
||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
|
||||
expect(result1.length).toBe(result2.length); // Same chords match
|
||||
});
|
||||
|
||||
it('should return pitch classes in ascending order', () => {
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
result.forEach(chord => {
|
||||
for (let i = 1; i < chord.length; i++) {
|
||||
expect(chord[i]).toBeGreaterThan(chord[i - 1])
|
||||
expect(chord[i]).toBeGreaterThan(chord[i - 1]);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply octave offset correctly for pitch classes >= 12', () => {
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
// All pitch classes should be < 12 after offset
|
||||
result.forEach(chord => {
|
||||
chord.forEach(pitchClass => {
|
||||
expect(pitchClass).toBeLessThan(24) // Allowing for extended range
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(pitchClass).toBeLessThan(24); // Allowing for extended range
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pitch class calculations', () => {
|
||||
it('should correctly convert hover pitch to pitch class', () => {
|
||||
// C4 (60) should match same chords as C5 (72)
|
||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T')
|
||||
expect(result1).toEqual(result2)
|
||||
})
|
||||
const result1 = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
const result2 = getMatchingChordsForPitch(72, 'C major', 'ionian', 'T');
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it('should maintain ascending pitch order in results', () => {
|
||||
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T')
|
||||
const result = getMatchingChordsForPitch(64, 'C major', 'ionian', 'T');
|
||||
result.forEach(chord => {
|
||||
for (let i = 1; i < chord.length; i++) {
|
||||
expect(chord[i]).toBeGreaterThan(chord[i - 1])
|
||||
expect(chord[i]).toBeGreaterThan(chord[i - 1]);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with helper functions', () => {
|
||||
it('should work with getSuitableChords', () => {
|
||||
const suitableChords = getSuitableChords('C major', 'ionian', 'T')
|
||||
expect(Object.keys(suitableChords).length).toBeGreaterThan(0)
|
||||
const suitableChords = getSuitableChords('C major', 'ionian', 'T');
|
||||
expect(Object.keys(suitableChords).length).toBeGreaterThan(0);
|
||||
|
||||
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
expect(matchingChords.length).toBeGreaterThan(0)
|
||||
})
|
||||
const matchingChords = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
expect(matchingChords.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should work with noteNameToPitchClass', () => {
|
||||
const cPitch = noteNameToPitchClass('C')
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T')
|
||||
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true)
|
||||
})
|
||||
const cPitch = noteNameToPitchClass('C');
|
||||
const result = getMatchingChordsForPitch(60, 'C major', 'ionian', 'T');
|
||||
expect(result.some(chord => chord.some(pc => pc % 12 === cPitch))).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect KGCore.FUNCTIONAL_CHORDS_DATA structure', () => {
|
||||
// Verify the data has the expected structure
|
||||
const data = KGCore.FUNCTIONAL_CHORDS_DATA
|
||||
expect(data).toHaveProperty('ionian')
|
||||
expect(data['ionian']).toHaveProperty('T')
|
||||
expect(data['ionian']).toHaveProperty('chords')
|
||||
})
|
||||
})
|
||||
})
|
||||
const data = KGCore.FUNCTIONAL_CHORDS_DATA;
|
||||
expect(data).toHaveProperty('ionian');
|
||||
expect(data['ionian']).toHaveProperty('T');
|
||||
expect(data['ionian']).toHaveProperty('chords');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePianoGridBackground', () => {
|
||||
it('should generate CSS background for C major ionian', () => {
|
||||
const result = generatePianoGridBackground('ionian', 'C major')
|
||||
expect(result).toContain('linear-gradient')
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
const result = generatePianoGridBackground('ionian', 'C major');
|
||||
expect(result).toContain('linear-gradient');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should generate different backgrounds for different modes', () => {
|
||||
const ionian = generatePianoGridBackground('ionian', 'C major')
|
||||
const dorian = generatePianoGridBackground('dorian', 'C major')
|
||||
expect(ionian).not.toBe(dorian)
|
||||
})
|
||||
})
|
||||
const ionian = generatePianoGridBackground('ionian', 'C major');
|
||||
const dorian = generatePianoGridBackground('dorian', 'C major');
|
||||
expect(ionian).not.toBe(dorian);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFunctionalChordsJSON', () => {
|
||||
const validJSON = `{
|
||||
@@ -434,14 +434,14 @@ describe('scaleUtil', () => {
|
||||
"♭VII": ["Bb", "D", "F"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
}`;
|
||||
|
||||
describe('valid JSON', () => {
|
||||
it('should validate correct JSON with ionian and aeolian', () => {
|
||||
const result = validateFunctionalChordsJSON(validJSON)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
const result = validateFunctionalChordsJSON(validJSON);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accept empty T/S/D arrays', () => {
|
||||
const json = `{
|
||||
@@ -453,10 +453,10 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept mode names with underscores, dashes, and spaces', () => {
|
||||
const json = `{
|
||||
@@ -468,10 +468,10 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept notes with sharp and flat', () => {
|
||||
const json = `{
|
||||
@@ -485,10 +485,10 @@ describe('scaleUtil', () => {
|
||||
"I": ["C#", "Eb", "F#"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept chord symbols with special characters', () => {
|
||||
const json = `{
|
||||
@@ -505,24 +505,24 @@ describe('scaleUtil', () => {
|
||||
"♭II": ["Db", "F", "Ab"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid JSON', () => {
|
||||
it('should reject malformed JSON', () => {
|
||||
const result = validateFunctionalChordsJSON('{ invalid json }')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Invalid JSON format')
|
||||
})
|
||||
const result = validateFunctionalChordsJSON('{ invalid json }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Invalid JSON format');
|
||||
});
|
||||
|
||||
it('should reject JSON array as root', () => {
|
||||
const result = validateFunctionalChordsJSON('[]')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Root must be an object')
|
||||
})
|
||||
const result = validateFunctionalChordsJSON('[]');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Root must be an object');
|
||||
});
|
||||
|
||||
it('should reject missing ionian mode', () => {
|
||||
const json = `{
|
||||
@@ -534,11 +534,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Missing required mode: "ionian"')
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Missing required mode: "ionian"');
|
||||
});
|
||||
|
||||
it('should reject invalid mode name', () => {
|
||||
const json = `{
|
||||
@@ -550,11 +550,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('"name" must be a string'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject steps with wrong number of elements', () => {
|
||||
const json = `{
|
||||
@@ -566,11 +566,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('must contain exactly 7 integers'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject steps that do not sum to 12', () => {
|
||||
const json = `{
|
||||
@@ -582,11 +582,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('must sum to 12'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-integer steps', () => {
|
||||
const json = `{
|
||||
@@ -598,11 +598,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('must contain only integers'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid chord symbol in T/S/D', () => {
|
||||
const json = `{
|
||||
@@ -616,11 +616,11 @@ describe('scaleUtil', () => {
|
||||
"invalid123": ["C", "E", "G"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid chord symbol'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject chord referenced but not defined', () => {
|
||||
const json = `{
|
||||
@@ -632,11 +632,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('referenced in T/S/D but not defined'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid note name', () => {
|
||||
const json = `{
|
||||
@@ -650,11 +650,11 @@ describe('scaleUtil', () => {
|
||||
"I": ["C", "X", "G"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject note with invalid accidental', () => {
|
||||
const json = `{
|
||||
@@ -668,11 +668,11 @@ describe('scaleUtil', () => {
|
||||
"I": ["C##", "E", "G"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject lowercase note names', () => {
|
||||
const json = `{
|
||||
@@ -686,12 +686,12 @@ describe('scaleUtil', () => {
|
||||
"I": ["c", "e", "g"]
|
||||
}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true)
|
||||
})
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes('Invalid note'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error messages', () => {
|
||||
it('should provide descriptive error messages for multiple errors', () => {
|
||||
@@ -704,11 +704,11 @@ describe('scaleUtil', () => {
|
||||
"D": [],
|
||||
"chords": {}
|
||||
}
|
||||
}`
|
||||
const result = validateFunctionalChordsJSON(json)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}`;
|
||||
const result = validateFunctionalChordsJSON(json);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+162
-162
@@ -1,316 +1,316 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil'
|
||||
import { TIME_CONSTANTS } from '../constants/coreConstants'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { parseTimeSignature, getTimeSignatureErrorMessage, beatsToTimeString, formatLocalDateTime } from './timeUtil';
|
||||
import { TIME_CONSTANTS } from '../constants/coreConstants';
|
||||
|
||||
describe('timeUtil', () => {
|
||||
describe('parseTimeSignature', () => {
|
||||
it('should parse valid time signatures', () => {
|
||||
expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 })
|
||||
expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 })
|
||||
expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 })
|
||||
expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 })
|
||||
expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 })
|
||||
})
|
||||
expect(parseTimeSignature('4/4')).toEqual({ numerator: 4, denominator: 4 });
|
||||
expect(parseTimeSignature('3/4')).toEqual({ numerator: 3, denominator: 4 });
|
||||
expect(parseTimeSignature('6/8')).toEqual({ numerator: 6, denominator: 8 });
|
||||
expect(parseTimeSignature('12/8')).toEqual({ numerator: 12, denominator: 8 });
|
||||
expect(parseTimeSignature('2/4')).toEqual({ numerator: 2, denominator: 4 });
|
||||
});
|
||||
|
||||
it('should handle whitespace around input', () => {
|
||||
expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 })
|
||||
expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 })
|
||||
expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 })
|
||||
})
|
||||
expect(parseTimeSignature(' 4/4 ')).toEqual({ numerator: 4, denominator: 4 });
|
||||
expect(parseTimeSignature(' 3/4 ')).toEqual({ numerator: 3, denominator: 4 });
|
||||
expect(parseTimeSignature('\t6/8\n')).toEqual({ numerator: 6, denominator: 8 });
|
||||
});
|
||||
|
||||
it('should return null for invalid formats', () => {
|
||||
expect(parseTimeSignature('4')).toBeNull()
|
||||
expect(parseTimeSignature('4/4/4')).toBeNull()
|
||||
expect(parseTimeSignature('4-4')).toBeNull()
|
||||
expect(parseTimeSignature('4:4')).toBeNull()
|
||||
expect(parseTimeSignature('')).toBeNull()
|
||||
expect(parseTimeSignature('/')).toBeNull()
|
||||
expect(parseTimeSignature('4/')).toBeNull()
|
||||
expect(parseTimeSignature('/4')).toBeNull()
|
||||
})
|
||||
expect(parseTimeSignature('4')).toBeNull();
|
||||
expect(parseTimeSignature('4/4/4')).toBeNull();
|
||||
expect(parseTimeSignature('4-4')).toBeNull();
|
||||
expect(parseTimeSignature('4:4')).toBeNull();
|
||||
expect(parseTimeSignature('')).toBeNull();
|
||||
expect(parseTimeSignature('/')).toBeNull();
|
||||
expect(parseTimeSignature('4/')).toBeNull();
|
||||
expect(parseTimeSignature('/4')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-numeric values', () => {
|
||||
expect(parseTimeSignature('a/4')).toBeNull()
|
||||
expect(parseTimeSignature('4/b')).toBeNull()
|
||||
expect(parseTimeSignature('x/y')).toBeNull()
|
||||
expect(parseTimeSignature('a/4')).toBeNull();
|
||||
expect(parseTimeSignature('4/b')).toBeNull();
|
||||
expect(parseTimeSignature('x/y')).toBeNull();
|
||||
// Note: parseInt('4.5') returns 4, so these will parse as integers
|
||||
// Testing the actual behavior of parseInt
|
||||
expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 })
|
||||
expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 })
|
||||
})
|
||||
expect(parseTimeSignature('4.5/4')).toEqual({ numerator: 4, denominator: 4 });
|
||||
expect(parseTimeSignature('4/4.5')).toEqual({ numerator: 4, denominator: 4 });
|
||||
});
|
||||
|
||||
it('should return null for numerators not in available list', () => {
|
||||
// Assuming TIME_CONSTANTS has specific available numerators
|
||||
expect(parseTimeSignature('99/4')).toBeNull()
|
||||
expect(parseTimeSignature('0/4')).toBeNull()
|
||||
expect(parseTimeSignature('-1/4')).toBeNull()
|
||||
})
|
||||
expect(parseTimeSignature('99/4')).toBeNull();
|
||||
expect(parseTimeSignature('0/4')).toBeNull();
|
||||
expect(parseTimeSignature('-1/4')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for denominators not in available list', () => {
|
||||
// Assuming TIME_CONSTANTS has specific available denominators
|
||||
expect(parseTimeSignature('4/99')).toBeNull()
|
||||
expect(parseTimeSignature('4/0')).toBeNull()
|
||||
expect(parseTimeSignature('4/-1')).toBeNull()
|
||||
})
|
||||
expect(parseTimeSignature('4/99')).toBeNull();
|
||||
expect(parseTimeSignature('4/0')).toBeNull();
|
||||
expect(parseTimeSignature('4/-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate against TIME_CONSTANTS available values', () => {
|
||||
// Test that function actually uses TIME_CONSTANTS for validation
|
||||
const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0]
|
||||
const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0]
|
||||
const invalidNumerator = 999 // Assuming this is not in the available list
|
||||
const invalidDenominator = 999 // Assuming this is not in the available list
|
||||
const validNumerator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS[0];
|
||||
const validDenominator = TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS[0];
|
||||
const invalidNumerator = 999; // Assuming this is not in the available list
|
||||
const invalidDenominator = 999; // Assuming this is not in the available list
|
||||
|
||||
expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull()
|
||||
expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull()
|
||||
expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
expect(parseTimeSignature(`${validNumerator}/${validDenominator}`)).not.toBeNull();
|
||||
expect(parseTimeSignature(`${invalidNumerator}/${validDenominator}`)).toBeNull();
|
||||
expect(parseTimeSignature(`${validNumerator}/${invalidDenominator}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeSignatureErrorMessage', () => {
|
||||
it('should return a formatted error message with available options', () => {
|
||||
const message = getTimeSignatureErrorMessage()
|
||||
const message = getTimeSignatureErrorMessage();
|
||||
|
||||
expect(message).toContain('Invalid time signature format')
|
||||
expect(message).toContain('numerator/denominator')
|
||||
expect(message).toContain('Available numerators:')
|
||||
expect(message).toContain('Available denominators:')
|
||||
expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8')
|
||||
})
|
||||
expect(message).toContain('Invalid time signature format');
|
||||
expect(message).toContain('numerator/denominator');
|
||||
expect(message).toContain('Available numerators:');
|
||||
expect(message).toContain('Available denominators:');
|
||||
expect(message).toContain('Examples: 4/4, 3/4, 6/8, 12/8');
|
||||
});
|
||||
|
||||
it('should include actual available values from TIME_CONSTANTS', () => {
|
||||
const message = getTimeSignatureErrorMessage()
|
||||
const message = getTimeSignatureErrorMessage();
|
||||
|
||||
// Check that it includes values from TIME_CONSTANTS
|
||||
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_NUMERATORS.forEach(numerator => {
|
||||
expect(message).toContain(numerator.toString())
|
||||
})
|
||||
expect(message).toContain(numerator.toString());
|
||||
});
|
||||
|
||||
TIME_CONSTANTS.AVAILABLE_TIME_SIGNATURE_DENOMINATORS.forEach(denominator => {
|
||||
expect(message).toContain(denominator.toString())
|
||||
})
|
||||
})
|
||||
expect(message).toContain(denominator.toString());
|
||||
});
|
||||
});
|
||||
|
||||
it('should be a consistent message format', () => {
|
||||
const message1 = getTimeSignatureErrorMessage()
|
||||
const message2 = getTimeSignatureErrorMessage()
|
||||
const message1 = getTimeSignatureErrorMessage();
|
||||
const message2 = getTimeSignatureErrorMessage();
|
||||
|
||||
expect(message1).toBe(message2)
|
||||
})
|
||||
})
|
||||
expect(message1).toBe(message2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('beatsToTimeString', () => {
|
||||
it('should format beats to BBB:B | mm:ss:mmm format', () => {
|
||||
// Test basic 4/4 time signature
|
||||
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('001:1 | 00:00:000')
|
||||
.toBe('001:1 | 00:00:000');
|
||||
|
||||
expect(beatsToTimeString(4, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('002:1 | 00:02:000')
|
||||
.toBe('002:1 | 00:02:000');
|
||||
|
||||
expect(beatsToTimeString(8, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('003:1 | 00:04:000')
|
||||
})
|
||||
.toBe('003:1 | 00:04:000');
|
||||
});
|
||||
|
||||
it('should handle different time signatures correctly', () => {
|
||||
// 3/4 time signature - 3 beats per bar
|
||||
expect(beatsToTimeString(0, 120, { numerator: 3, denominator: 4 }))
|
||||
.toBe('001:1 | 00:00:000')
|
||||
.toBe('001:1 | 00:00:000');
|
||||
|
||||
expect(beatsToTimeString(3, 120, { numerator: 3, denominator: 4 }))
|
||||
.toBe('002:1 | 00:01:500')
|
||||
.toBe('002:1 | 00:01:500');
|
||||
|
||||
expect(beatsToTimeString(6, 120, { numerator: 3, denominator: 4 }))
|
||||
.toBe('003:1 | 00:03:000')
|
||||
.toBe('003:1 | 00:03:000');
|
||||
|
||||
// 6/8 time signature - 6 beats per bar
|
||||
expect(beatsToTimeString(0, 120, { numerator: 6, denominator: 8 }))
|
||||
.toBe('001:1 | 00:00:000')
|
||||
.toBe('001:1 | 00:00:000');
|
||||
|
||||
expect(beatsToTimeString(6, 120, { numerator: 6, denominator: 8 }))
|
||||
.toBe('002:1 | 00:03:000')
|
||||
})
|
||||
.toBe('002:1 | 00:03:000');
|
||||
});
|
||||
|
||||
it('should handle different BPM values correctly', () => {
|
||||
// 60 BPM - 1 beat per second
|
||||
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
||||
.toBe('001:2 | 00:01:000')
|
||||
.toBe('001:2 | 00:01:000');
|
||||
|
||||
expect(beatsToTimeString(4, 60, { numerator: 4, denominator: 4 }))
|
||||
.toBe('002:1 | 00:04:000')
|
||||
.toBe('002:1 | 00:04:000');
|
||||
|
||||
// 240 BPM - 4 beats per second
|
||||
expect(beatsToTimeString(1, 240, { numerator: 4, denominator: 4 }))
|
||||
.toBe('001:2 | 00:00:250')
|
||||
.toBe('001:2 | 00:00:250');
|
||||
|
||||
expect(beatsToTimeString(4, 240, { numerator: 4, denominator: 4 }))
|
||||
.toBe('002:1 | 00:01:000')
|
||||
})
|
||||
.toBe('002:1 | 00:01:000');
|
||||
});
|
||||
|
||||
it('should handle fractional beats correctly', () => {
|
||||
expect(beatsToTimeString(1.5, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('001:2 | 00:00:750')
|
||||
.toBe('001:2 | 00:00:750');
|
||||
|
||||
expect(beatsToTimeString(2.25, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('001:3 | 00:01:125')
|
||||
.toBe('001:3 | 00:01:125');
|
||||
|
||||
expect(beatsToTimeString(4.75, 120, { numerator: 4, denominator: 4 }))
|
||||
.toBe('002:1 | 00:02:375')
|
||||
})
|
||||
.toBe('002:1 | 00:02:375');
|
||||
});
|
||||
|
||||
it('should pad numbers correctly in output format', () => {
|
||||
// Test bar padding (BBB format)
|
||||
expect(beatsToTimeString(0, 120, { numerator: 4, denominator: 4 }))
|
||||
.toMatch(/^001:/)
|
||||
.toMatch(/^001:/);
|
||||
|
||||
expect(beatsToTimeString(40, 120, { numerator: 4, denominator: 4 }))
|
||||
.toMatch(/^011:/) // Bar 11
|
||||
.toMatch(/^011:/); // Bar 11
|
||||
|
||||
expect(beatsToTimeString(396, 120, { numerator: 4, denominator: 4 }))
|
||||
.toMatch(/^100:/) // Bar 100
|
||||
.toMatch(/^100:/); // Bar 100
|
||||
|
||||
// Test time padding (mm:ss:mmm format)
|
||||
expect(beatsToTimeString(1, 60, { numerator: 4, denominator: 4 }))
|
||||
.toMatch(/\| 00:01:000$/)
|
||||
.toMatch(/\| 00:01:000$/);
|
||||
|
||||
expect(beatsToTimeString(75, 60, { numerator: 4, denominator: 4 }))
|
||||
.toMatch(/\| 01:15:000$/) // 1 minute 15 seconds
|
||||
})
|
||||
.toMatch(/\| 01:15:000$/); // 1 minute 15 seconds
|
||||
});
|
||||
|
||||
it('should handle large beat values', () => {
|
||||
const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 })
|
||||
expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/)
|
||||
})
|
||||
const result = beatsToTimeString(1000, 120, { numerator: 4, denominator: 4 });
|
||||
expect(result).toMatch(/^251:1 \| \d{2}:\d{2}:\d{3}$/);
|
||||
});
|
||||
|
||||
it('should handle edge case of zero BPM gracefully', () => {
|
||||
// This might cause division by zero, should handle gracefully
|
||||
expect(() => beatsToTimeString(1, 0, { numerator: 4, denominator: 4 }))
|
||||
.not.toThrow()
|
||||
})
|
||||
.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle negative beats', () => {
|
||||
// Edge case - negative beats may produce negative values in time format
|
||||
const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 })
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe('string')
|
||||
const result = beatsToTimeString(-1, 120, { numerator: 4, denominator: 4 });
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
// The function may produce negative time values for negative beats
|
||||
expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/)
|
||||
})
|
||||
})
|
||||
expect(result).toMatch(/^\d{3}:\d \| -?\d+:-?\d+:-?\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLocalDateTime', () => {
|
||||
let mockDate: Date
|
||||
let mockDate: Date;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use a fixed date for consistent testing
|
||||
mockDate = new Date('2025-08-21T19:57:11.123Z')
|
||||
})
|
||||
mockDate = new Date('2025-08-21T19:57:11.123Z');
|
||||
});
|
||||
|
||||
it('should format date with correct structure', () => {
|
||||
const result = formatLocalDateTime(mockDate)
|
||||
const result = formatLocalDateTime(mockDate);
|
||||
|
||||
// Should contain date parts
|
||||
expect(result).toMatch(/\d{4}/) // Year
|
||||
expect(result).toMatch(/\d{2}/) // Month/day/hour/minute/second
|
||||
expect(result).toContain(':') // Time separator
|
||||
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/) // Timezone
|
||||
})
|
||||
expect(result).toMatch(/\d{4}/); // Year
|
||||
expect(result).toMatch(/\d{2}/); // Month/day/hour/minute/second
|
||||
expect(result).toContain(':'); // Time separator
|
||||
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}/); // Timezone
|
||||
});
|
||||
|
||||
it('should use 24-hour format', () => {
|
||||
const morningDate = new Date('2025-08-21T09:30:00Z')
|
||||
const eveningDate = new Date('2025-08-21T21:30:00Z')
|
||||
const morningDate = new Date('2025-08-21T09:30:00Z');
|
||||
const eveningDate = new Date('2025-08-21T21:30:00Z');
|
||||
|
||||
const morningResult = formatLocalDateTime(morningDate)
|
||||
const eveningResult = formatLocalDateTime(eveningDate)
|
||||
const morningResult = formatLocalDateTime(morningDate);
|
||||
const eveningResult = formatLocalDateTime(eveningDate);
|
||||
|
||||
// Should not contain AM/PM indicators
|
||||
expect(morningResult).not.toMatch(/AM|PM/i)
|
||||
expect(eveningResult).not.toMatch(/AM|PM/i)
|
||||
})
|
||||
expect(morningResult).not.toMatch(/AM|PM/i);
|
||||
expect(eveningResult).not.toMatch(/AM|PM/i);
|
||||
});
|
||||
|
||||
it('should include timezone information', () => {
|
||||
const result = formatLocalDateTime(mockDate)
|
||||
const result = formatLocalDateTime(mockDate);
|
||||
|
||||
// Should contain some timezone indicator
|
||||
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/)
|
||||
})
|
||||
expect(result).toMatch(/GMT[+-]\d+|UTC|[A-Z]{3,4}|\+\d{4}|-\d{4}/);
|
||||
});
|
||||
|
||||
it('should handle different dates consistently', () => {
|
||||
const dates = [
|
||||
new Date('2025-01-01T00:00:00Z'),
|
||||
new Date('2025-06-15T12:30:45Z'),
|
||||
new Date('2025-12-31T23:59:59Z')
|
||||
]
|
||||
];
|
||||
|
||||
dates.forEach(date => {
|
||||
const result = formatLocalDateTime(date)
|
||||
expect(result).toBeDefined()
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
const result = formatLocalDateTime(date);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle edge dates', () => {
|
||||
const edgeDates = [
|
||||
new Date('1970-01-01T00:00:00Z'), // Unix epoch
|
||||
new Date('2038-01-19T03:14:07Z'), // Near 32-bit timestamp limit
|
||||
new Date('2100-12-31T23:59:59Z') // Future date
|
||||
]
|
||||
];
|
||||
|
||||
edgeDates.forEach(date => {
|
||||
expect(() => formatLocalDateTime(date)).not.toThrow()
|
||||
const result = formatLocalDateTime(date)
|
||||
expect(typeof result).toBe('string')
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
expect(() => formatLocalDateTime(date)).not.toThrow();
|
||||
const result = formatLocalDateTime(date);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be consistent for the same date', () => {
|
||||
const result1 = formatLocalDateTime(mockDate)
|
||||
const result2 = formatLocalDateTime(mockDate)
|
||||
const result1 = formatLocalDateTime(mockDate);
|
||||
const result2 = formatLocalDateTime(mockDate);
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
|
||||
it('should handle leap year dates', () => {
|
||||
const leapYearDate = new Date('2024-02-29T12:00:00Z') // Leap year
|
||||
const leapYearDate = new Date('2024-02-29T12:00:00Z'); // Leap year
|
||||
|
||||
expect(() => formatLocalDateTime(leapYearDate)).not.toThrow()
|
||||
const result = formatLocalDateTime(leapYearDate)
|
||||
expect(result).toContain('2024')
|
||||
expect(result).toContain('02')
|
||||
expect(result).toContain('29')
|
||||
})
|
||||
})
|
||||
expect(() => formatLocalDateTime(leapYearDate)).not.toThrow();
|
||||
const result = formatLocalDateTime(leapYearDate);
|
||||
expect(result).toContain('2024');
|
||||
expect(result).toContain('02');
|
||||
expect(result).toContain('29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('should work together for typical DAW workflow', () => {
|
||||
// Parse time signature
|
||||
const timeSignature = parseTimeSignature('4/4')
|
||||
expect(timeSignature).not.toBeNull()
|
||||
const timeSignature = parseTimeSignature('4/4');
|
||||
expect(timeSignature).not.toBeNull();
|
||||
|
||||
// Use parsed time signature in time formatting
|
||||
const timeString = beatsToTimeString(16, 120, timeSignature!)
|
||||
expect(timeString).toBe('005:1 | 00:08:000')
|
||||
const timeString = beatsToTimeString(16, 120, timeSignature!);
|
||||
expect(timeString).toBe('005:1 | 00:08:000');
|
||||
|
||||
// Format current time
|
||||
const now = new Date()
|
||||
const formattedTime = formatLocalDateTime(now)
|
||||
expect(formattedTime).toBeDefined()
|
||||
})
|
||||
const now = new Date();
|
||||
const formattedTime = formatLocalDateTime(now);
|
||||
expect(formattedTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle error cases gracefully in workflow', () => {
|
||||
// Invalid time signature should not break workflow
|
||||
const invalidTimeSignature = parseTimeSignature('invalid')
|
||||
expect(invalidTimeSignature).toBeNull()
|
||||
const invalidTimeSignature = parseTimeSignature('invalid');
|
||||
expect(invalidTimeSignature).toBeNull();
|
||||
|
||||
// Get error message for user feedback
|
||||
const errorMessage = getTimeSignatureErrorMessage()
|
||||
expect(errorMessage).toContain('Invalid time signature')
|
||||
const errorMessage = getTimeSignatureErrorMessage();
|
||||
expect(errorMessage).toContain('Invalid time signature');
|
||||
|
||||
// Fallback to default time signature
|
||||
const fallbackTimeSignature = { numerator: 4, denominator: 4 }
|
||||
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature)
|
||||
expect(timeString).toBe('003:1 | 00:04:000')
|
||||
})
|
||||
})
|
||||
})
|
||||
const fallbackTimeSignature = { numerator: 4, denominator: 4 };
|
||||
const timeString = beatsToTimeString(8, 120, fallbackTimeSignature);
|
||||
expect(timeString).toBe('003:1 | 00:04:000');
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
-204
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractXMLFromString, wrapXmlBlocksInContent } from './xmlUtil';
|
||||
import {
|
||||
longTextWithAddNotes,
|
||||
longTextWithReadMusic,
|
||||
@@ -9,323 +9,323 @@ import {
|
||||
malformedXml,
|
||||
noXmlContent,
|
||||
emptyAndWhitespaceXml
|
||||
} from '../test/fixtures/xml-samples'
|
||||
} from '../test/fixtures/xml-samples';
|
||||
|
||||
describe('xmlUtil', () => {
|
||||
describe('extractXMLFromString', () => {
|
||||
it('should extract simple XML blocks', () => {
|
||||
const input = `Here is some text with <test>content</test> and more text.`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `Here is some text with <test>content</test> and more text.`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<test>content</test>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<test>content</test>');
|
||||
});
|
||||
|
||||
it('should extract multiple XML blocks', () => {
|
||||
const input = `<first>content1</first> some text <second>content2</second>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<first>content1</first> some text <second>content2</second>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toBe('<first>content1</first>')
|
||||
expect(result[1]).toBe('<second>content2</second>')
|
||||
})
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('<first>content1</first>');
|
||||
expect(result[1]).toBe('<second>content2</second>');
|
||||
});
|
||||
|
||||
it('should extract XML blocks with nested elements', () => {
|
||||
const input = `<outer><inner>nested content</inner></outer>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<outer><inner>nested content</inner></outer>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<outer><inner>nested content</inner></outer>');
|
||||
});
|
||||
|
||||
it('should extract XML blocks with attributes', () => {
|
||||
const input = `<tag attr="value" id="123">content</tag>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<tag attr="value" id="123">content</tag>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<tag attr="value" id="123">content</tag>');
|
||||
});
|
||||
|
||||
it('should handle multiline XML blocks', () => {
|
||||
const input = `<multiline>
|
||||
<line1>content1</line1>
|
||||
<line2>content2</line2>
|
||||
</multiline>`
|
||||
const result = extractXMLFromString(input)
|
||||
</multiline>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContain('<multiline>')
|
||||
expect(result[0]).toContain('<line1>content1</line1>')
|
||||
expect(result[0]).toContain('<line2>content2</line2>')
|
||||
expect(result[0]).toContain('</multiline>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('<multiline>');
|
||||
expect(result[0]).toContain('<line1>content1</line1>');
|
||||
expect(result[0]).toContain('<line2>content2</line2>');
|
||||
expect(result[0]).toContain('</multiline>');
|
||||
});
|
||||
|
||||
it('should extract XML from complex nested content fixture', () => {
|
||||
const result = extractXMLFromString(nestedXmlContent)
|
||||
const result = extractXMLFromString(nestedXmlContent);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContain('<container>')
|
||||
expect(result[0]).toContain('<inner_element>')
|
||||
expect(result[0]).toContain('<deep_nested>')
|
||||
expect(result[0]).toContain('<value>Test Content</value>')
|
||||
expect(result[0]).toContain('</container>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('<container>');
|
||||
expect(result[0]).toContain('<inner_element>');
|
||||
expect(result[0]).toContain('<deep_nested>');
|
||||
expect(result[0]).toContain('<value>Test Content</value>');
|
||||
expect(result[0]).toContain('</container>');
|
||||
});
|
||||
|
||||
it('should extract multiple XML blocks from fixture', () => {
|
||||
const result = extractXMLFromString(multipleXmlBlocks)
|
||||
const result = extractXMLFromString(multipleXmlBlocks);
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toContain('<add_notes>')
|
||||
expect(result[0]).toContain('</add_notes>')
|
||||
expect(result[1]).toContain('<read_music>')
|
||||
expect(result[1]).toContain('</read_music>')
|
||||
expect(result[2]).toContain('<modify_tempo>')
|
||||
expect(result[2]).toContain('</modify_tempo>')
|
||||
})
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toContain('<add_notes>');
|
||||
expect(result[0]).toContain('</add_notes>');
|
||||
expect(result[1]).toContain('<read_music>');
|
||||
expect(result[1]).toContain('</read_music>');
|
||||
expect(result[2]).toContain('<modify_tempo>');
|
||||
expect(result[2]).toContain('</modify_tempo>');
|
||||
});
|
||||
|
||||
it('should extract XML with attributes from fixture', () => {
|
||||
const result = extractXMLFromString(xmlWithAttributes)
|
||||
const result = extractXMLFromString(xmlWithAttributes);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toContain('region_id="main"')
|
||||
expect(result[0]).toContain('track="melody"')
|
||||
expect(result[0]).toContain('id="1"')
|
||||
expect(result[0]).toContain('velocity="127"')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('region_id="main"');
|
||||
expect(result[0]).toContain('track="melody"');
|
||||
expect(result[0]).toContain('id="1"');
|
||||
expect(result[0]).toContain('velocity="127"');
|
||||
});
|
||||
|
||||
it('should handle the long text with add_notes fixture (Case 1)', () => {
|
||||
const result = extractXMLFromString(longTextWithAddNotes)
|
||||
const result = extractXMLFromString(longTextWithAddNotes);
|
||||
|
||||
expect(result).toHaveLength(2) // Contains thinking tag and add_notes block
|
||||
expect(result).toHaveLength(2); // Contains thinking tag and add_notes block
|
||||
|
||||
// Find the add_notes block
|
||||
const addNotesBlock = result.find(block => block.includes('<add_notes>'))
|
||||
expect(addNotesBlock).toBeDefined()
|
||||
expect(addNotesBlock).toContain('<notes>')
|
||||
expect(addNotesBlock).toContain('<note>')
|
||||
expect(addNotesBlock).toContain('<pitch>C4</pitch>')
|
||||
expect(addNotesBlock).toContain('<start_beat>0</start_beat>')
|
||||
expect(addNotesBlock).toContain('<length>4</length>')
|
||||
expect(addNotesBlock).toContain('</add_notes>')
|
||||
const addNotesBlock = result.find(block => block.includes('<add_notes>'));
|
||||
expect(addNotesBlock).toBeDefined();
|
||||
expect(addNotesBlock).toContain('<notes>');
|
||||
expect(addNotesBlock).toContain('<note>');
|
||||
expect(addNotesBlock).toContain('<pitch>C4</pitch>');
|
||||
expect(addNotesBlock).toContain('<start_beat>0</start_beat>');
|
||||
expect(addNotesBlock).toContain('<length>4</length>');
|
||||
expect(addNotesBlock).toContain('</add_notes>');
|
||||
|
||||
// Should contain all 12 notes
|
||||
const noteMatches = addNotesBlock!.match(/<note>/g)
|
||||
expect(noteMatches).toHaveLength(12)
|
||||
})
|
||||
const noteMatches = addNotesBlock!.match(/<note>/g);
|
||||
expect(noteMatches).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('should handle the long text with read_music fixture (Case 2)', () => {
|
||||
const result = extractXMLFromString(longTextWithReadMusic)
|
||||
const result = extractXMLFromString(longTextWithReadMusic);
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// First XML block (inside thinking tag)
|
||||
expect(result[0]).toContain('<read_music>')
|
||||
expect(result[0]).toContain('<start_beat>0</start_beat>')
|
||||
expect(result[0]).toContain('<length>32</length>')
|
||||
expect(result[0]).toContain('</read_music>')
|
||||
expect(result[0]).toContain('<read_music>');
|
||||
expect(result[0]).toContain('<start_beat>0</start_beat>');
|
||||
expect(result[0]).toContain('<length>32</length>');
|
||||
expect(result[0]).toContain('</read_music>');
|
||||
|
||||
// Second XML block (at the end)
|
||||
expect(result[1]).toContain('<read_music>')
|
||||
expect(result[1]).toContain('<start_beat>0</start_beat>')
|
||||
expect(result[1]).toContain('<length>32</length>')
|
||||
expect(result[1]).toContain('</read_music>')
|
||||
})
|
||||
expect(result[1]).toContain('<read_music>');
|
||||
expect(result[1]).toContain('<start_beat>0</start_beat>');
|
||||
expect(result[1]).toContain('<length>32</length>');
|
||||
expect(result[1]).toContain('</read_music>');
|
||||
});
|
||||
|
||||
it('should handle malformed XML gracefully', () => {
|
||||
const result = extractXMLFromString(malformedXml)
|
||||
const result = extractXMLFromString(malformedXml);
|
||||
|
||||
// Should extract valid XML blocks (ignores malformed ones)
|
||||
expect(result.length).toBeGreaterThanOrEqual(1)
|
||||
expect(result.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find the definitely valid block
|
||||
const validBlock = result.find(block => block.includes('<valid_tag>'))
|
||||
expect(validBlock).toBeDefined()
|
||||
expect(validBlock).toContain('<content>This is valid</content>')
|
||||
})
|
||||
const validBlock = result.find(block => block.includes('<valid_tag>'));
|
||||
expect(validBlock).toBeDefined();
|
||||
expect(validBlock).toContain('<content>This is valid</content>');
|
||||
});
|
||||
|
||||
it('should return empty array for content with no XML', () => {
|
||||
const result = extractXMLFromString(noXmlContent)
|
||||
const result = extractXMLFromString(noXmlContent);
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
expect(result).toHaveLength(0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty and whitespace XML', () => {
|
||||
const result = extractXMLFromString(emptyAndWhitespaceXml)
|
||||
const result = extractXMLFromString(emptyAndWhitespaceXml);
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toBe('<empty_tag></empty_tag>')
|
||||
expect(result[1]).toContain('<whitespace_tag>')
|
||||
expect(result[1]).toContain('</whitespace_tag>')
|
||||
expect(result[2]).toContain('<mixed_content>')
|
||||
expect(result[2]).toContain('Some text with spaces')
|
||||
expect(result[2]).toContain('</mixed_content>')
|
||||
})
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toBe('<empty_tag></empty_tag>');
|
||||
expect(result[1]).toContain('<whitespace_tag>');
|
||||
expect(result[1]).toContain('</whitespace_tag>');
|
||||
expect(result[2]).toContain('<mixed_content>');
|
||||
expect(result[2]).toContain('Some text with spaces');
|
||||
expect(result[2]).toContain('</mixed_content>');
|
||||
});
|
||||
|
||||
it('should handle XML with underscores and hyphens in tag names', () => {
|
||||
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<tag_name>content</tag_name> and <tag-name>content</tag-name>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toBe('<tag_name>content</tag_name>')
|
||||
expect(result[1]).toBe('<tag-name>content</tag-name>')
|
||||
})
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('<tag_name>content</tag_name>');
|
||||
expect(result[1]).toBe('<tag-name>content</tag-name>');
|
||||
});
|
||||
|
||||
it('should handle self-closing tags (not currently supported)', () => {
|
||||
const input = `<self-closing /> and <normal>content</normal>`
|
||||
const result = extractXMLFromString(input)
|
||||
const input = `<self-closing /> and <normal>content</normal>`;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
// Current implementation doesn't support self-closing tags
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<normal>content</normal>')
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<normal>content</normal>');
|
||||
});
|
||||
|
||||
it('should trim whitespace around extracted XML', () => {
|
||||
const input = ` <tag>content</tag> `
|
||||
const result = extractXMLFromString(input)
|
||||
const input = ` <tag>content</tag> `;
|
||||
const result = extractXMLFromString(input);
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toBe('<tag>content</tag>')
|
||||
})
|
||||
})
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe('<tag>content</tag>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapXmlBlocksInContent', () => {
|
||||
it('should wrap single XML block in fenced code block', () => {
|
||||
const input = `Here is <test>content</test> in text.`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `Here is <test>content</test> in text.`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.')
|
||||
})
|
||||
expect(result).toBe('Here is ```xml\n<test>content</test>\n``` in text.');
|
||||
});
|
||||
|
||||
it('should wrap multiple XML blocks', () => {
|
||||
const input = `<first>content1</first> text <second>content2</second>`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `<first>content1</first> text <second>content2</second>`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toContain('```xml\n<first>content1</first>\n```')
|
||||
expect(result).toContain('```xml\n<second>content2</second>\n```')
|
||||
})
|
||||
expect(result).toContain('```xml\n<first>content1</first>\n```');
|
||||
expect(result).toContain('```xml\n<second>content2</second>\n```');
|
||||
});
|
||||
|
||||
it('should return original content when no XML blocks present', () => {
|
||||
const input = noXmlContent
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = noXmlContent;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(wrapXmlBlocksInContent('')).toBe('')
|
||||
expect(wrapXmlBlocksInContent(null as any)).toBeNull()
|
||||
expect(wrapXmlBlocksInContent(undefined as any)).toBeUndefined()
|
||||
})
|
||||
expect(wrapXmlBlocksInContent('')).toBe('');
|
||||
expect(wrapXmlBlocksInContent(null as unknown as string)).toBeNull();
|
||||
expect(wrapXmlBlocksInContent(undefined as unknown as string)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should wrap XML blocks from multipleXmlBlocks fixture', () => {
|
||||
const result = wrapXmlBlocksInContent(multipleXmlBlocks)
|
||||
const result = wrapXmlBlocksInContent(multipleXmlBlocks);
|
||||
|
||||
expect(result).toContain('```xml\n<add_notes>')
|
||||
expect(result).toContain('</add_notes>\n```')
|
||||
expect(result).toContain('```xml\n<read_music>')
|
||||
expect(result).toContain('</read_music>\n```')
|
||||
expect(result).toContain('```xml\n<modify_tempo>')
|
||||
expect(result).toContain('</modify_tempo>\n```')
|
||||
expect(result).toContain('```xml\n<add_notes>');
|
||||
expect(result).toContain('</add_notes>\n```');
|
||||
expect(result).toContain('```xml\n<read_music>');
|
||||
expect(result).toContain('</read_music>\n```');
|
||||
expect(result).toContain('```xml\n<modify_tempo>');
|
||||
expect(result).toContain('</modify_tempo>\n```');
|
||||
|
||||
// Should preserve the surrounding text
|
||||
expect(result).toContain('Here\'s how to add multiple musical elements:')
|
||||
expect(result).toContain('First, let\'s add some notes:')
|
||||
expect(result).toContain('Then we can read the current music:')
|
||||
})
|
||||
expect(result).toContain('Here\'s how to add multiple musical elements:');
|
||||
expect(result).toContain('First, let\'s add some notes:');
|
||||
expect(result).toContain('Then we can read the current music:');
|
||||
});
|
||||
|
||||
it('should wrap the long add_notes XML block (Case 1)', () => {
|
||||
const result = wrapXmlBlocksInContent(longTextWithAddNotes)
|
||||
const result = wrapXmlBlocksInContent(longTextWithAddNotes);
|
||||
|
||||
expect(result).toContain('```xml\n<add_notes>')
|
||||
expect(result).toContain('</add_notes>\n```')
|
||||
expect(result).toContain('```xml\n<add_notes>');
|
||||
expect(result).toContain('</add_notes>\n```');
|
||||
|
||||
// Should preserve the thinking content and other text
|
||||
expect(result).toContain('<thinking>')
|
||||
expect(result).toContain('Perfect! I can see this is a beautiful')
|
||||
expect(result).toContain('Let me start by adding the harmony')
|
||||
})
|
||||
expect(result).toContain('<thinking>');
|
||||
expect(result).toContain('Perfect! I can see this is a beautiful');
|
||||
expect(result).toContain('Let me start by adding the harmony');
|
||||
});
|
||||
|
||||
it('should wrap the long read_music XML blocks (Case 2)', () => {
|
||||
const result = wrapXmlBlocksInContent(longTextWithReadMusic)
|
||||
const result = wrapXmlBlocksInContent(longTextWithReadMusic);
|
||||
|
||||
// Should contain two wrapped XML blocks
|
||||
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g)
|
||||
expect(fencedBlocks).toHaveLength(2)
|
||||
const fencedBlocks = result.match(/```xml\n<read_music>[\s\S]*?<\/read_music>\n```/g);
|
||||
expect(fencedBlocks).toHaveLength(2);
|
||||
|
||||
// Should preserve the thinking content and other text
|
||||
expect(result).toContain('<thinking>')
|
||||
expect(result).toContain('I\'ll help you create a pad harmony track')
|
||||
expect(result).toContain('Let me first read the existing music')
|
||||
})
|
||||
expect(result).toContain('<thinking>');
|
||||
expect(result).toContain('I\'ll help you create a pad harmony track');
|
||||
expect(result).toContain('Let me first read the existing music');
|
||||
});
|
||||
|
||||
it('should handle nested XML correctly', () => {
|
||||
const result = wrapXmlBlocksInContent(nestedXmlContent)
|
||||
const result = wrapXmlBlocksInContent(nestedXmlContent);
|
||||
|
||||
expect(result).toContain('```xml\n<container>')
|
||||
expect(result).toContain('<inner_element>')
|
||||
expect(result).toContain('<deep_nested>')
|
||||
expect(result).toContain('<value>Test Content</value>')
|
||||
expect(result).toContain('</container>\n```')
|
||||
})
|
||||
expect(result).toContain('```xml\n<container>');
|
||||
expect(result).toContain('<inner_element>');
|
||||
expect(result).toContain('<deep_nested>');
|
||||
expect(result).toContain('<value>Test Content</value>');
|
||||
expect(result).toContain('</container>\n```');
|
||||
});
|
||||
|
||||
it('should preserve XML block integrity when wrapping', () => {
|
||||
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `Text before\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\nText after`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`)
|
||||
})
|
||||
expect(result).toBe(`Text before\n\`\`\`xml\n<complex>\n <nested>value</nested>\n <another>content</another>\n</complex>\n\`\`\`\nText after`);
|
||||
});
|
||||
|
||||
it('should handle duplicate XML blocks correctly', () => {
|
||||
const input = `<same>content</same> and then <same>content</same> again`
|
||||
const result = wrapXmlBlocksInContent(input)
|
||||
const input = `<same>content</same> and then <same>content</same> again`;
|
||||
const result = wrapXmlBlocksInContent(input);
|
||||
|
||||
// Both instances should be wrapped
|
||||
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g)
|
||||
expect(wrappedBlocks).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
const wrappedBlocks = result.match(/```xml\n<same>content<\/same>\n```/g);
|
||||
expect(wrappedBlocks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and error handling', () => {
|
||||
it('should handle very large XML blocks', () => {
|
||||
const largeContent = 'x'.repeat(10000)
|
||||
const input = `<large>${largeContent}</large>`
|
||||
const largeContent = 'x'.repeat(10000);
|
||||
const input = `<large>${largeContent}</large>`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toContain(largeContent)
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toContain(largeContent);
|
||||
|
||||
const wrapped = wrapXmlBlocksInContent(input)
|
||||
expect(wrapped).toContain('```xml\n<large>')
|
||||
expect(wrapped).toContain('</large>\n```')
|
||||
})
|
||||
const wrapped = wrapXmlBlocksInContent(input);
|
||||
expect(wrapped).toContain('```xml\n<large>');
|
||||
expect(wrapped).toContain('</large>\n```');
|
||||
});
|
||||
|
||||
it('should handle XML with special characters', () => {
|
||||
const input = `<tag>Content with < > & " '</tag>`
|
||||
const input = `<tag>Content with < > & " '</tag>`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toContain('< > & " '')
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toContain('< > & " '');
|
||||
|
||||
const wrapped = wrapXmlBlocksInContent(input)
|
||||
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```')
|
||||
})
|
||||
const wrapped = wrapXmlBlocksInContent(input);
|
||||
expect(wrapped).toContain('```xml\n<tag>Content with < > & " '</tag>\n```');
|
||||
});
|
||||
|
||||
it('should handle XML with CDATA sections', () => {
|
||||
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`
|
||||
const input = `<tag><![CDATA[Some content with <special> chars]]></tag>`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toContain('<![CDATA[')
|
||||
expect(extracted[0]).toContain(']]>')
|
||||
})
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toContain('<![CDATA[');
|
||||
expect(extracted[0]).toContain(']]>');
|
||||
});
|
||||
|
||||
it('should handle mixed content with partial XML-like text', () => {
|
||||
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`
|
||||
const input = `This < is not XML and neither > is this <incomplete and <valid>content</valid> is valid`;
|
||||
|
||||
const extracted = extractXMLFromString(input)
|
||||
expect(extracted).toHaveLength(1)
|
||||
expect(extracted[0]).toBe('<valid>content</valid>')
|
||||
})
|
||||
})
|
||||
})
|
||||
const extracted = extractXMLFromString(input);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toBe('<valid>content</valid>');
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
Reference in New Issue
Block a user