feat: added delete_track tool
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DeleteTrackTool } from './DeleteTrackTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('DeleteTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
removeTrackSynth: vi.fn(),
|
||||
removeTrackAudioPlayerBus: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('deletes a MIDI track by track_id', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-by-id-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track deleted:\ntrack_id: 1\ntrack_name: Lead',
|
||||
});
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Bass']);
|
||||
expect(tool.buildToolResultDisplayContent({ track_id: '1' }, result)).toBe(
|
||||
'Track deleted:\n- track_id: 1\n- track_name: Lead',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a MIDI track by track_name', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-by-name-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_name: 'Bass' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track deleted:\ntrack_id: 2\ntrack_name: Bass',
|
||||
});
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
|
||||
});
|
||||
|
||||
it('uses track_id when both track_id and track_name are provided', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-track-id-precedence-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '2',
|
||||
track_name: 'Lead',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
|
||||
});
|
||||
|
||||
it('rejects duplicate track names when track_id is omitted', async () => {
|
||||
const firstLead = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const secondLead = new KGMidiTrack('Lead', 2, 'flute');
|
||||
const project = new KGProject('delete-duplicate-name-project');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_name: 'Lead' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when neither identifier is provided', async () => {
|
||||
const project = new KGProject('delete-missing-id-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Either track_id or track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when the target track does not exist', async () => {
|
||||
const project = new KGProject('delete-missing-track-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '99' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "99" not found or is not a MIDI track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when the target is not a MIDI track', async () => {
|
||||
const audioTrack = new KGAudioTrack('Vocal', 1);
|
||||
const project = new KGProject('delete-audio-track-project');
|
||||
project.setTracks([audioTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "1" not found or is not a MIDI track.',
|
||||
});
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('generates confirmation content only for valid target input', () => {
|
||||
const tool = new DeleteTrackTool();
|
||||
|
||||
expect(tool.buildConfirmationContent({ track_id: '1' })).toBe('Allow deleting track ID **1**?');
|
||||
expect(tool.buildConfirmationContent({ track_name: 'Lead' })).toBe('Allow deleting track **Lead**?');
|
||||
expect(tool.buildConfirmationContent({})).toBeUndefined();
|
||||
expect(tool.buildConfirmationContent(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not generate display content for failed or malformed results', () => {
|
||||
const tool = new DeleteTrackTool();
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(
|
||||
{ track_id: '1' },
|
||||
{ success: false, result: 'Track with ID "1" not found or is not a MIDI track.' },
|
||||
)).toBeUndefined();
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(
|
||||
{ track_id: '1' },
|
||||
{ success: true, result: 'Track deleted.' },
|
||||
)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { RemoveTrackCommand } from '../../core/commands/track/RemoveTrackCommand';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { resolveMidiTrackByExactName, resolveMidiTrackByIdOrName } from './toolTargeting';
|
||||
|
||||
export class DeleteTrackTool extends BaseTool {
|
||||
readonly name = 'delete_track';
|
||||
readonly description =
|
||||
'Delete an existing MIDI track by track_id or track_name. Prefer track_id because track_name may be duplicated.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track ID. Preferred when available.',
|
||||
required: false,
|
||||
},
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track name. Used only when track_id is omitted.',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof args.track_id === 'string') {
|
||||
return `Allow deleting track ID **${args.track_id}**?`;
|
||||
}
|
||||
|
||||
if (typeof args.track_name === 'string') {
|
||||
return `Allow deleting track **${args.track_name}**?`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/);
|
||||
const trackNameMatch = toolResult.result.match(/track_name:\s*(.+)/);
|
||||
if (!trackIdMatch || !trackNameMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'Track deleted:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackNameMatch[1]}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const trackName = params.track_name as string | undefined;
|
||||
|
||||
if (!trackId && !trackName) {
|
||||
return this.createErrorResult('Either track_id or track_name must be provided.');
|
||||
}
|
||||
|
||||
if (!trackId && trackName) {
|
||||
const matchingTracks = resolveMidiTrackByExactName(trackName);
|
||||
if (matchingTracks.length > 1) {
|
||||
return this.createErrorResult(`Multiple MIDI tracks share the name "${trackName}". Provide track_id instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTrack = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!resolvedTrack) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(resolvedTrack instanceof KGMidiTrack)) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
const deletedTrackId = resolvedTrack.getId().toString();
|
||||
const deletedTrackName = resolvedTrack.getName();
|
||||
const command = new RemoveTrackCommand(resolvedTrack.getId());
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'Track deleted:',
|
||||
`track_id: ${deletedTrackId}`,
|
||||
`track_name: ${deletedTrackName}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to delete track: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ListAllTracksTool } from './ListAllTracksTool';
|
||||
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
|
||||
import { CreateNewTrackTool } from './CreateNewTrackTool';
|
||||
import { UpdateTrackTool } from './UpdateTrackTool';
|
||||
import { DeleteTrackTool } from './DeleteTrackTool';
|
||||
|
||||
export {
|
||||
AddNotesTool,
|
||||
@@ -26,6 +27,7 @@ export {
|
||||
ListAllAvailableInstrumentsTool,
|
||||
CreateNewTrackTool,
|
||||
UpdateTrackTool,
|
||||
DeleteTrackTool,
|
||||
};
|
||||
|
||||
// Tool registry for easy access
|
||||
@@ -40,6 +42,7 @@ export const AVAILABLE_TOOLS = {
|
||||
list_all_available_instruments: ListAllAvailableInstrumentsTool,
|
||||
create_new_track: CreateNewTrackTool,
|
||||
update_track: UpdateTrackTool,
|
||||
delete_track: DeleteTrackTool,
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
|
||||
Reference in New Issue
Block a user