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:
Xiaohan-Tian
2026-05-01 14:37:30 -07:00
committed by GitHub
58 changed files with 3091 additions and 2234 deletions
+3 -1
View File
@@ -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
View File
@@ -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';
+6 -6
View File
@@ -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;
+58 -2
View File
@@ -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' : ''}`}
+3 -58
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -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 -2
View File
@@ -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';
+8
View File
@@ -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;
+1 -1
View File
@@ -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;
+26 -2
View File
@@ -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>('');
+29 -1
View File
@@ -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;
+1 -1
View File
@@ -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';
+96 -7
View File
@@ -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>
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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`);
}
}
}
+1 -1
View File
@@ -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, {
+4 -11
View File
@@ -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);
});
});
+57 -6
View File
@@ -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 =====
/**
+4 -11
View File
@@ -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();
});
});
+28
View File
@@ -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;
}
}
+3 -1
View File
@@ -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: ''
+1 -2
View File
@@ -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;
+16
View File
@@ -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 {
+89 -89
View File
@@ -1,151 +1,151 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiNote } from './KGMidiNote'
import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiNote } from './KGMidiNote';
describe('KGMidiNote', () => {
let note: KGMidiNote
let note: KGMidiNote;
beforeEach(() => {
note = new KGMidiNote('test-note', 0, 1, 60, 80)
})
note = new KGMidiNote('test-note', 0, 1, 60, 80);
});
describe('constructor', () => {
it('should create a note with correct properties', () => {
const testNote = new KGMidiNote('note-1', 2, 4, 72, 100)
const testNote = new KGMidiNote('note-1', 2, 4, 72, 100);
expect(testNote.getId()).toBe('note-1')
expect(testNote.getStartBeat()).toBe(2)
expect(testNote.getEndBeat()).toBe(4)
expect(testNote.getPitch()).toBe(72)
expect(testNote.getVelocity()).toBe(100)
})
expect(testNote.getId()).toBe('note-1');
expect(testNote.getStartBeat()).toBe(2);
expect(testNote.getEndBeat()).toBe(4);
expect(testNote.getPitch()).toBe(72);
expect(testNote.getVelocity()).toBe(100);
});
it('should use default values when not provided', () => {
const defaultNote = new KGMidiNote('default-note')
const defaultNote = new KGMidiNote('default-note');
expect(defaultNote.getId()).toBe('default-note')
expect(defaultNote.getStartBeat()).toBe(0)
expect(defaultNote.getEndBeat()).toBe(0)
expect(defaultNote.getPitch()).toBe(0)
expect(defaultNote.getVelocity()).toBe(127)
})
})
expect(defaultNote.getId()).toBe('default-note');
expect(defaultNote.getStartBeat()).toBe(0);
expect(defaultNote.getEndBeat()).toBe(0);
expect(defaultNote.getPitch()).toBe(0);
expect(defaultNote.getVelocity()).toBe(127);
});
});
describe('getters and setters', () => {
it('should get and set start beat', () => {
expect(note.getStartBeat()).toBe(0)
expect(note.getStartBeat()).toBe(0);
note.setStartBeat(1.5)
expect(note.getStartBeat()).toBe(1.5)
})
note.setStartBeat(1.5);
expect(note.getStartBeat()).toBe(1.5);
});
it('should get and set end beat', () => {
expect(note.getEndBeat()).toBe(1)
expect(note.getEndBeat()).toBe(1);
note.setEndBeat(3.5)
expect(note.getEndBeat()).toBe(3.5)
})
note.setEndBeat(3.5);
expect(note.getEndBeat()).toBe(3.5);
});
it('should get and set pitch', () => {
expect(note.getPitch()).toBe(60)
expect(note.getPitch()).toBe(60);
note.setPitch(72)
expect(note.getPitch()).toBe(72)
})
note.setPitch(72);
expect(note.getPitch()).toBe(72);
});
it('should get and set velocity', () => {
expect(note.getVelocity()).toBe(80)
expect(note.getVelocity()).toBe(80);
note.setVelocity(100)
expect(note.getVelocity()).toBe(100)
})
note.setVelocity(100);
expect(note.getVelocity()).toBe(100);
});
it('should get and set ID', () => {
expect(note.getId()).toBe('test-note')
expect(note.getId()).toBe('test-note');
note.setId('new-id')
expect(note.getId()).toBe('new-id')
})
})
note.setId('new-id');
expect(note.getId()).toBe('new-id');
});
});
describe('selection', () => {
it('should start unselected', () => {
expect(note.isSelected()).toBe(false)
})
expect(note.isSelected()).toBe(false);
});
it('should select and deselect', () => {
note.select()
expect(note.isSelected()).toBe(true)
note.select();
expect(note.isSelected()).toBe(true);
note.deselect()
expect(note.isSelected()).toBe(false)
})
})
note.deselect();
expect(note.isSelected()).toBe(false);
});
});
describe('note duration', () => {
it('should calculate duration correctly', () => {
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80)
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2)
})
const durationNote = new KGMidiNote('duration-test', 1, 3, 60, 80);
expect(durationNote.getEndBeat() - durationNote.getStartBeat()).toBe(2);
});
it('should handle zero duration', () => {
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80)
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0)
})
})
const zeroDurationNote = new KGMidiNote('zero-duration', 2, 2, 60, 80);
expect(zeroDurationNote.getEndBeat() - zeroDurationNote.getStartBeat()).toBe(0);
});
});
describe('pitch validation', () => {
it('should accept valid MIDI pitch range', () => {
// MIDI pitch range is typically 0-127
note.setPitch(0)
expect(note.getPitch()).toBe(0)
note.setPitch(0);
expect(note.getPitch()).toBe(0);
note.setPitch(127)
expect(note.getPitch()).toBe(127)
note.setPitch(127);
expect(note.getPitch()).toBe(127);
note.setPitch(60) // Middle C
expect(note.getPitch()).toBe(60)
})
})
note.setPitch(60); // Middle C
expect(note.getPitch()).toBe(60);
});
});
describe('velocity validation', () => {
it('should accept valid MIDI velocity range', () => {
// MIDI velocity range is typically 0-127
note.setVelocity(0)
expect(note.getVelocity()).toBe(0)
note.setVelocity(0);
expect(note.getVelocity()).toBe(0);
note.setVelocity(127)
expect(note.getVelocity()).toBe(127)
note.setVelocity(127);
expect(note.getVelocity()).toBe(127);
note.setVelocity(64) // Mid velocity
expect(note.getVelocity()).toBe(64)
})
})
note.setVelocity(64); // Mid velocity
expect(note.getVelocity()).toBe(64);
});
});
describe('clone and comparison', () => {
it('should create independent instances', () => {
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80)
const note2 = new KGMidiNote('note-2', 0, 1, 60, 80)
const note1 = new KGMidiNote('note-1', 0, 1, 60, 80);
const note2 = new KGMidiNote('note-2', 0, 1, 60, 80);
expect(note1.getId()).not.toBe(note2.getId())
expect(note1.getId()).not.toBe(note2.getId());
note1.setPitch(72)
expect(note2.getPitch()).toBe(60) // Should remain unchanged
})
})
note1.setPitch(72);
expect(note2.getPitch()).toBe(60); // Should remain unchanged
});
});
describe('edge cases', () => {
it('should handle negative start beat', () => {
note.setStartBeat(-1)
expect(note.getStartBeat()).toBe(-1)
})
note.setStartBeat(-1);
expect(note.getStartBeat()).toBe(-1);
});
it('should handle start beat after end beat', () => {
note.setStartBeat(5)
note.setEndBeat(2)
note.setStartBeat(5);
note.setEndBeat(2);
expect(note.getStartBeat()).toBe(5)
expect(note.getEndBeat()).toBe(2)
expect(note.getStartBeat()).toBe(5);
expect(note.getEndBeat()).toBe(2);
// Note: The class might need validation logic to prevent this
})
})
})
});
});
});
@@ -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 01 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);
});
});
+23
View File
@@ -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 01 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;
}
+210 -210
View File
@@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiRegion } from './KGMidiRegion'
import { KGRegion } from './KGRegion'
import { KGMidiNote } from '../midi/KGMidiNote'
import { createMockMidiNote } from '../../test/utils/mock-data'
import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiRegion } from './KGMidiRegion';
import { KGRegion } from './KGRegion';
import { KGMidiNote } from '../midi/KGMidiNote';
import { createMockMidiNote } from '../../test/utils/mock-data';
describe('KGMidiRegion', () => {
let region: KGMidiRegion
let region: KGMidiRegion;
beforeEach(() => {
region = new KGMidiRegion(
@@ -15,8 +15,8 @@ describe('KGMidiRegion', () => {
'Test Region',
0,
4
)
})
);
});
describe('constructor', () => {
it('should create a MIDI region with correct properties', () => {
@@ -27,327 +27,327 @@ describe('KGMidiRegion', () => {
'My Region',
4,
8
)
);
expect(testRegion.getId()).toBe('region-1')
expect(testRegion.getTrackId()).toBe('track-1')
expect(testRegion.getTrackIndex()).toBe(2)
expect(testRegion.getName()).toBe('My Region')
expect(testRegion.getStartFromBeat()).toBe(4)
expect(testRegion.getLength()).toBe(8)
expect(testRegion.getNotes()).toEqual([])
})
expect(testRegion.getId()).toBe('region-1');
expect(testRegion.getTrackId()).toBe('track-1');
expect(testRegion.getTrackIndex()).toBe(2);
expect(testRegion.getName()).toBe('My Region');
expect(testRegion.getStartFromBeat()).toBe(4);
expect(testRegion.getLength()).toBe(8);
expect(testRegion.getNotes()).toEqual([]);
});
it('should use default values for optional parameters', () => {
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region')
const defaultRegion = new KGMidiRegion('region-1', 'track-1', 0, 'Default Region');
expect(defaultRegion.getStartFromBeat()).toBe(0)
expect(defaultRegion.getLength()).toBe(0)
expect(defaultRegion.getNotes()).toEqual([])
})
expect(defaultRegion.getStartFromBeat()).toBe(0);
expect(defaultRegion.getLength()).toBe(0);
expect(defaultRegion.getNotes()).toEqual([]);
});
it('should set the correct type identifier', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
})
expect(region.getCurrentType()).toBe('KGMidiRegion');
});
});
describe('note management', () => {
let note1: KGMidiNote
let note2: KGMidiNote
let note3: KGMidiNote
let note1: KGMidiNote;
let note2: KGMidiNote;
let note3: KGMidiNote;
beforeEach(() => {
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 })
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 })
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 })
})
note1 = createMockMidiNote({ id: 'note-1', pitch: 60, startBeat: 0, endBeat: 1 });
note2 = createMockMidiNote({ id: 'note-2', pitch: 64, startBeat: 1, endBeat: 2 });
note3 = createMockMidiNote({ id: 'note-3', pitch: 67, startBeat: 2, endBeat: 3 });
});
describe('addNote', () => {
it('should add a single note to empty region', () => {
region.addNote(note1)
region.addNote(note1);
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note1)
})
const notes = region.getNotes();
expect(notes).toHaveLength(1);
expect(notes[0]).toBe(note1);
});
it('should add multiple notes to region', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
region.addNote(note1);
region.addNote(note2);
region.addNote(note3);
const notes = region.getNotes()
expect(notes).toHaveLength(3)
expect(notes).toContain(note1)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
})
const notes = region.getNotes();
expect(notes).toHaveLength(3);
expect(notes).toContain(note1);
expect(notes).toContain(note2);
expect(notes).toContain(note3);
});
it('should maintain note order when adding', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
region.addNote(note1);
region.addNote(note2);
region.addNote(note3);
const notes = region.getNotes()
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note2)
expect(notes[2]).toBe(note3)
})
const notes = region.getNotes();
expect(notes[0]).toBe(note1);
expect(notes[1]).toBe(note2);
expect(notes[2]).toBe(note3);
});
it('should allow adding the same note multiple times', () => {
region.addNote(note1)
region.addNote(note1)
region.addNote(note1);
region.addNote(note1);
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes[0]).toBe(note1)
expect(notes[1]).toBe(note1)
})
})
const notes = region.getNotes();
expect(notes).toHaveLength(2);
expect(notes[0]).toBe(note1);
expect(notes[1]).toBe(note1);
});
});
describe('removeNote', () => {
beforeEach(() => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note3)
})
region.addNote(note1);
region.addNote(note2);
region.addNote(note3);
});
it('should remove note by ID', () => {
region.removeNote('note-2')
region.removeNote('note-2');
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note1)
expect(notes).toContain(note3)
expect(notes).not.toContain(note2)
})
const notes = region.getNotes();
expect(notes).toHaveLength(2);
expect(notes).toContain(note1);
expect(notes).toContain(note3);
expect(notes).not.toContain(note2);
});
it('should handle removing non-existent note gracefully', () => {
const initialLength = region.getNotes().length
const initialLength = region.getNotes().length;
region.removeNote('non-existent-note')
region.removeNote('non-existent-note');
expect(region.getNotes()).toHaveLength(initialLength)
})
expect(region.getNotes()).toHaveLength(initialLength);
});
it('should remove all instances when note ID appears multiple times', () => {
// Add another note with same ID as note1
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 })
region.addNote(duplicateNote)
const duplicateNote = createMockMidiNote({ id: 'note-1', pitch: 72, startBeat: 3, endBeat: 4 });
region.addNote(duplicateNote);
expect(region.getNotes()).toHaveLength(4)
expect(region.getNotes()).toHaveLength(4);
region.removeNote('note-1')
region.removeNote('note-1');
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(note2)
expect(notes).toContain(note3)
expect(notes).not.toContain(note1)
expect(notes).not.toContain(duplicateNote)
})
const notes = region.getNotes();
expect(notes).toHaveLength(2);
expect(notes).toContain(note2);
expect(notes).toContain(note3);
expect(notes).not.toContain(note1);
expect(notes).not.toContain(duplicateNote);
});
it('should handle removing from empty region', () => {
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region')
const emptyRegion = new KGMidiRegion('empty', 'track', 0, 'Empty Region');
expect(() => emptyRegion.removeNote('note-1')).not.toThrow()
expect(emptyRegion.getNotes()).toHaveLength(0)
})
})
expect(() => emptyRegion.removeNote('note-1')).not.toThrow();
expect(emptyRegion.getNotes()).toHaveLength(0);
});
});
describe('getNotes', () => {
it('should return empty array for new region', () => {
const notes = region.getNotes()
expect(notes).toEqual([])
expect(notes).toHaveLength(0)
})
const notes = region.getNotes();
expect(notes).toEqual([]);
expect(notes).toHaveLength(0);
});
it('should return all notes in region', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note1);
region.addNote(note2);
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toEqual([note1, note2])
})
const notes = region.getNotes();
expect(notes).toHaveLength(2);
expect(notes).toEqual([note1, note2]);
});
it('should return a reference to the internal notes array', () => {
region.addNote(note1)
const notes1 = region.getNotes()
const notes2 = region.getNotes()
region.addNote(note1);
const notes1 = region.getNotes();
const notes2 = region.getNotes();
expect(notes1).toBe(notes2) // Same reference
})
})
expect(notes1).toBe(notes2); // Same reference
});
});
describe('setNotes', () => {
it('should replace all notes with new array', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note1);
region.addNote(note2);
expect(region.getNotes()).toHaveLength(2)
expect(region.getNotes()).toHaveLength(2);
region.setNotes([note3])
region.setNotes([note3]);
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(note3)
})
const notes = region.getNotes();
expect(notes).toHaveLength(1);
expect(notes[0]).toBe(note3);
});
it('should allow setting empty notes array', () => {
region.addNote(note1)
region.addNote(note2)
region.addNote(note1);
region.addNote(note2);
region.setNotes([])
region.setNotes([]);
expect(region.getNotes()).toHaveLength(0)
})
expect(region.getNotes()).toHaveLength(0);
});
it('should accept notes array with multiple notes', () => {
const newNotes = [note1, note2, note3]
region.setNotes(newNotes)
const newNotes = [note1, note2, note3];
region.setNotes(newNotes);
const retrievedNotes = region.getNotes()
expect(retrievedNotes).toHaveLength(3)
expect(retrievedNotes).toEqual(newNotes)
})
})
})
const retrievedNotes = region.getNotes();
expect(retrievedNotes).toHaveLength(3);
expect(retrievedNotes).toEqual(newNotes);
});
});
});
describe('inheritance from KGRegion', () => {
it('should inherit all base region properties', () => {
expect(region.getId()).toBe('test-region-1')
expect(region.getTrackId()).toBe('test-track-1')
expect(region.getTrackIndex()).toBe(0)
expect(region.getName()).toBe('Test Region')
expect(region.getStartFromBeat()).toBe(0)
expect(region.getLength()).toBe(4)
})
expect(region.getId()).toBe('test-region-1');
expect(region.getTrackId()).toBe('test-track-1');
expect(region.getTrackIndex()).toBe(0);
expect(region.getName()).toBe('Test Region');
expect(region.getStartFromBeat()).toBe(0);
expect(region.getLength()).toBe(4);
});
it('should inherit selection functionality', () => {
expect(region.isSelected()).toBe(false)
expect(region.isSelected()).toBe(false);
region.select()
expect(region.isSelected()).toBe(true)
region.select();
expect(region.isSelected()).toBe(true);
region.deselect()
expect(region.isSelected()).toBe(false)
})
region.deselect();
expect(region.isSelected()).toBe(false);
});
it('should inherit setters from base class', () => {
region.setName('Updated Region')
expect(region.getName()).toBe('Updated Region')
region.setName('Updated Region');
expect(region.getName()).toBe('Updated Region');
region.setStartFromBeat(8)
expect(region.getStartFromBeat()).toBe(8)
region.setStartFromBeat(8);
expect(region.getStartFromBeat()).toBe(8);
region.setLength(12)
expect(region.getLength()).toBe(12)
})
})
region.setLength(12);
expect(region.getLength()).toBe(12);
});
});
describe('type identification', () => {
it('should return correct current type', () => {
expect(region.getCurrentType()).toBe('KGMidiRegion')
})
expect(region.getCurrentType()).toBe('KGMidiRegion');
});
it('should return correct root type', () => {
expect(region.getRootType()).toBe('KGRegion')
})
expect(region.getRootType()).toBe('KGRegion');
});
it('should be instanceof both KGMidiRegion and KGRegion', () => {
expect(region).toBeInstanceOf(KGMidiRegion)
expect(region).toBeInstanceOf(KGRegion)
})
})
expect(region).toBeInstanceOf(KGMidiRegion);
expect(region).toBeInstanceOf(KGRegion);
});
});
describe('edge cases and error handling', () => {
it('should handle notes with overlapping time ranges', () => {
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 })
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 })
const overlappingNote1 = createMockMidiNote({ id: 'overlap-1', pitch: 60, startBeat: 0, endBeat: 2 });
const overlappingNote2 = createMockMidiNote({ id: 'overlap-2', pitch: 64, startBeat: 1, endBeat: 3 });
region.addNote(overlappingNote1)
region.addNote(overlappingNote2)
region.addNote(overlappingNote1);
region.addNote(overlappingNote2);
const notes = region.getNotes()
expect(notes).toHaveLength(2)
expect(notes).toContain(overlappingNote1)
expect(notes).toContain(overlappingNote2)
})
const notes = region.getNotes();
expect(notes).toHaveLength(2);
expect(notes).toContain(overlappingNote1);
expect(notes).toContain(overlappingNote2);
});
it('should handle notes with same pitch but different timing', () => {
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 })
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 })
const sameNote1 = createMockMidiNote({ id: 'same-1', pitch: 60, startBeat: 0, endBeat: 1 });
const sameNote2 = createMockMidiNote({ id: 'same-2', pitch: 60, startBeat: 2, endBeat: 3 });
region.addNote(sameNote1)
region.addNote(sameNote2)
region.addNote(sameNote1);
region.addNote(sameNote2);
expect(region.getNotes()).toHaveLength(2)
})
expect(region.getNotes()).toHaveLength(2);
});
it('should handle notes outside region boundaries', () => {
// Region is from beat 0 to 4, but note extends beyond
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 })
const outsideNote = createMockMidiNote({ id: 'outside', pitch: 60, startBeat: 3, endBeat: 6 });
region.addNote(outsideNote)
region.addNote(outsideNote);
const notes = region.getNotes()
expect(notes).toHaveLength(1)
expect(notes[0]).toBe(outsideNote)
const notes = region.getNotes();
expect(notes).toHaveLength(1);
expect(notes[0]).toBe(outsideNote);
// Note: The region doesn't enforce boundary constraints - that's application logic
})
});
it('should handle zero-length region', () => {
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0)
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 })
const zeroRegion = new KGMidiRegion('zero', 'track', 0, 'Zero Length', 0, 0);
const note = createMockMidiNote({ id: 'note', pitch: 60, startBeat: 0, endBeat: 1 });
zeroRegion.addNote(note)
zeroRegion.addNote(note);
expect(zeroRegion.getNotes()).toHaveLength(1)
expect(zeroRegion.getLength()).toBe(0)
})
})
expect(zeroRegion.getNotes()).toHaveLength(1);
expect(zeroRegion.getLength()).toBe(0);
});
});
describe('data consistency', () => {
it('should maintain note references correctly', () => {
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 })
const originalNote = createMockMidiNote({ id: 'ref-test', pitch: 60, startBeat: 0, endBeat: 1 });
region.addNote(originalNote)
const retrievedNote = region.getNotes()[0]
region.addNote(originalNote);
const retrievedNote = region.getNotes()[0];
expect(retrievedNote).toBe(originalNote) // Same reference
expect(retrievedNote).toBe(originalNote); // Same reference
// Modify original note
originalNote.setPitch(64)
expect(retrievedNote.getPitch()).toBe(64) // Should reflect change
})
originalNote.setPitch(64);
expect(retrievedNote.getPitch()).toBe(64); // Should reflect change
});
it('should handle concurrent modifications correctly', () => {
const notes = [
createMockMidiNote({ id: 'concurrent-1', pitch: 60 }),
createMockMidiNote({ id: 'concurrent-2', pitch: 64 }),
createMockMidiNote({ id: 'concurrent-3', pitch: 67 })
]
];
// Add notes
notes.forEach(note => region.addNote(note))
expect(region.getNotes()).toHaveLength(3)
notes.forEach(note => region.addNote(note));
expect(region.getNotes()).toHaveLength(3);
// Remove middle note
region.removeNote('concurrent-2')
expect(region.getNotes()).toHaveLength(2)
region.removeNote('concurrent-2');
expect(region.getNotes()).toHaveLength(2);
// Add new note
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 })
region.addNote(newNote)
expect(region.getNotes()).toHaveLength(3)
const newNote = createMockMidiNote({ id: 'concurrent-4', pitch: 70 });
region.addNote(newNote);
expect(region.getNotes()).toHaveLength(3);
// Verify final state
const finalNotes = region.getNotes()
expect(finalNotes).toContain(notes[0]) // concurrent-1
expect(finalNotes).not.toContain(notes[1]) // concurrent-2 (removed)
expect(finalNotes).toContain(notes[2]) // concurrent-3
expect(finalNotes).toContain(newNote) // concurrent-4
})
})
})
const finalNotes = region.getNotes();
expect(finalNotes).toContain(notes[0]); // concurrent-1
expect(finalNotes).not.toContain(notes[1]); // concurrent-2 (removed)
expect(finalNotes).toContain(notes[2]); // concurrent-3
expect(finalNotes).toContain(newNote); // concurrent-4
});
});
});
+208 -202
View File
@@ -1,70 +1,70 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack'
import { KGTrack, TrackType } from './KGTrack'
import { KGMidiRegion } from '../region/KGMidiRegion'
import { createMockMidiRegion } from '../../test/utils/mock-data'
import { describe, it, expect, beforeEach } from 'vitest';
import { KGMidiTrack, type InstrumentType } from './KGMidiTrack';
import { KGTrack, TrackType } from './KGTrack';
import { KGMidiRegion } from '../region/KGMidiRegion';
import { createMockMidiRegion } from '../../test/utils/mock-data';
describe('KGMidiTrack', () => {
let track: KGMidiTrack
let track: KGMidiTrack;
beforeEach(() => {
track = new KGMidiTrack()
})
track = new KGMidiTrack();
});
describe('constructor', () => {
it('should create track with default values', () => {
const defaultTrack = new KGMidiTrack()
const defaultTrack = new KGMidiTrack();
expect(defaultTrack.getName()).toBe('Untitled MIDI Track')
expect(defaultTrack.getId()).toBe(0)
expect(defaultTrack.getType()).toBe(TrackType.MIDI)
expect(defaultTrack.getInstrument()).toBe('acoustic_grand_piano')
expect(defaultTrack.getVolume()).toBe(0.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);
});
});
});
});
+4 -1
View File
@@ -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 {
+42 -2
View File
@@ -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
View File
@@ -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();
});
});
});
+3 -3
View File
@@ -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);
+20 -17
View File
@@ -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();
};
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
};
+31 -31
View File
@@ -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,
}
}
};
};
+6 -6
View File
@@ -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 };
+65
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &lt; &gt; &amp; &quot; &apos;</tag>`
const input = `<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>`;
const extracted = extractXMLFromString(input)
expect(extracted).toHaveLength(1)
expect(extracted[0]).toContain('&lt; &gt; &amp; &quot; &apos;')
const extracted = extractXMLFromString(input);
expect(extracted).toHaveLength(1);
expect(extracted[0]).toContain('&lt; &gt; &amp; &quot; &apos;');
const wrapped = wrapXmlBlocksInContent(input)
expect(wrapped).toContain('```xml\n<tag>Content with &lt; &gt; &amp; &quot; &apos;</tag>\n```')
})
const wrapped = wrapXmlBlocksInContent(input);
expect(wrapped).toContain('```xml\n<tag>Content with &lt; &gt; &amp; &quot; &apos;</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>');
});
});
});
+1 -1
View File
@@ -1,3 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
declare const __APP_VERSION__: string;