import { describe, expect, it } from 'vitest'; import type { InstrumentAsset } from '../../src/core/model/Asset'; import { chipPresetGraph } from '../../src/core/instrument/chipPresets'; import { bakeUploadName } from '../../src/core/music/bake'; import { settleRouting } from '../../src/core/music/projectRouting'; import type { StoredFile } from '../../src/core/store/FileStore'; import { runTool } from '../../src/editor/agent/agentTools'; import type { FileAssetType } from '../../src/editor/agent/storedFiles'; import { makeSpriteScene, type SceneFixture } from '../helpers'; import { hostFor, text } from './agentTestHost'; /** Files the server holds (#431), presets of instruments (#432) and what uses an asset (#433). */ const stored = (file: string, name: string, extra: object = {}): StoredFile => ({ file, name, bytes: 1000, updatedAt: '', owner: 'me', deletable: true, ...extra, }); const PICTURE = stored(`${'b2'.repeat(32)}.png`, 'hero-idle.png', { width: 32, height: 16, demo: true }); const UNREAD = stored(`${'b3'.repeat(32)}.png`, 'broken.png', { width: 0, height: 0 }); const BLIP = stored(`${'c4'.repeat(32)}.wav`, 'blip.wav'); const BAKED = stored(`${'c5'.repeat(32)}.ogg`, bakeUploadName('Theme')); const CLIP = stored(`${'d6'.repeat(32)}.mp4`, 'intro.mp4', { status: 'ready', width: 640, height: 360, duration: 4, fps: 30, audio: true, }); const QUEUED = stored(`${'d7'.repeat(32)}.mp4`, 'later.mp4', { status: 'queued' }); const SERVER: Record = { texture: [PICTURE, UNREAD], font: [], sound: [BLIP, BAKED], video: [CLIP, QUEUED], }; function filesHost(fixture: SceneFixture) { const made = hostFor(fixture); const asked: FileAssetType[] = []; made.host.files = (type) => { asked.push(type); return Promise.resolve(SERVER[type]); }; return { ...made, asked }; } const run = async (fixture: SceneFixture, steps: object[]) => { const { host, asked } = filesHost(fixture); const result = await runTool('run_commands', { steps }, host); return { result, asked, text: text(result) }; }; const list = async (fixture: SceneFixture, type: FileAssetType, query?: string) => ( (await runTool('list_files', { type, ...(query ? { query } : {}) }, filesHost(fixture).host)) .structuredContent as { files: Record[]; } ).files; const read = async (fixture: SceneFixture, asset: string) => (await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record; const named = (fixture: SceneFixture, name: string) => fixture.project.assets.find((a) => a.name === name)!; describe('stored files', () => { it('lists the files of a kind as the picker offers them, with the asset wearing one', async () => { const fixture = makeSpriteScene(); expect(await list(fixture, 'texture')).toEqual([ { file: PICTURE.file, name: 'hero-idle.png', bytes: 1000, width: 32, height: 16, demo: true }, { file: UNREAD.file, name: 'broken.png', bytes: 1000, width: 0, height: 0 }, ]); expect((await list(fixture, 'texture', 'HERO')).map((f) => f.file)).toEqual([PICTURE.file]); // A bake is the editor's own copy of a song, not one of the user's sounds. expect((await list(fixture, 'sound')).map((f) => f.file)).toEqual([BLIP.file]); expect(await list(fixture, 'video')).toEqual([ { file: CLIP.file, name: 'intro.mp4', bytes: 1000, status: 'ready', width: 640, height: 360, seconds: 4, fps: 30, audio: true, }, { file: QUEUED.file, name: 'later.mp4', bytes: 1000, status: 'queued' }, ]); const done = await run(fixture, [ { command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file } }, ]); expect(done.result.isError).toBeUndefined(); const asset = named(fixture, 'hero-idle'); expect((await list(fixture, 'texture'))[0]!.asset).toEqual({ id: asset.id, name: 'hero-idle' }); const without = hostFor(fixture).host; expect(text(await runTool('list_files', { type: 'sound' }, without))).toBe('This editor lists no files'); }); it('makes an asset of a stored file as the picker does, one undo step, and refuses what cannot be one', async () => { const fixture = makeSpriteScene(); const done = await run(fixture, [ { command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file }, as: 'hero' }, { command: 'add_file_asset', args: { type: 'sound', file: BLIP.file, name: 'Jump' } }, { command: 'add_file_asset', args: { type: 'video', file: CLIP.file } }, { command: 'set_asset_property', args: { asset: fixture.spriteId, key: 'texture', value: '$hero' } }, ]); expect(done.result.isError).toBeUndefined(); // Only the lists the steps need are fetched, once each. expect(done.asked).toEqual(['texture', 'sound', 'video']); const picture = named(fixture, 'hero-idle'); expect(picture).toMatchObject({ type: 'texture', file: PICTURE.file, width: 32, height: 16 }); expect(named(fixture, 'Jump')).toMatchObject({ type: 'sound', file: BLIP.file }); expect(named(fixture, 'intro')).toMatchObject({ type: 'video', file: CLIP.file, fps: 30, audio: true }); expect(done.text).toContain(`"id":"${picture.id}"`); const refused = async (args: object) => { const before = fixture.project.assets.length; const outcome = await run(fixture, [{ command: 'add_file_asset', args }]); expect(outcome.result.isError).toBe(true); expect(fixture.project.assets).toHaveLength(before); return outcome.text; }; expect(await refused({ type: 'texture', file: PICTURE.file })).toContain( `is in the project already as "hero-idle" (${picture.id}): use that one`, ); expect(await refused({ type: 'texture', file: UNREAD.file })).toContain('could not read the size'); expect(await refused({ type: 'video', file: QUEUED.file })).toContain('is queued, not converted yet'); expect(await refused({ type: 'sound', file: BAKED.file })).toContain('The server holds no sound file'); expect(await refused({ type: 'sound', file: PICTURE.file })).toContain('The server holds no sound file'); fixture.stack.undo(); expect(fixture.project.assets.some((a) => a.type === 'texture' || a.type === 'sound')).toBe(false); }); }); describe('instrument presets', () => { it('replaces the graph of an instrument with a preset, so its notes are heard, and refuses anything else', async () => { const fixture = makeSpriteScene(); await run(fixture, [ { command: 'create_asset', args: { type: 'instrument', name: 'Bass' }, as: 'bass' }, { command: 'create_asset', args: { type: 'pattern', name: 'Line' }, as: 'line' }, { command: 'set_asset_property', args: { asset: '$line', key: 'instrument', value: '$bass' } }, { command: 'add_notes', args: { asset: '$line', notes: [{ tick: 0, length: 480, note: 'C2' }] } }, ]); const bass = named(fixture, 'Bass') as InstrumentAsset; const line = named(fixture, 'Line'); expect((await read(fixture, line.id)).silent).toBeDefined(); const done = await run(fixture, [ { command: 'apply_preset', args: { asset: bass.id, preset: 'acidBass' } }, ]); expect(done.result.isError).toBeUndefined(); const types = (graph: { nodes: { type: string }[] }) => graph.nodes.map((n) => n.type).sort(); expect(types(bass.graph)).toEqual(types(chipPresetGraph('acidBass'))); expect(fixture.stack.undoLabel).toContain('apply_preset'); expect((await read(fixture, line.id)).silent).toBeUndefined(); fixture.stack.undo(); expect(types(bass.graph)).not.toEqual(types(chipPresetGraph('acidBass'))); const wrong = await run(fixture, [ { command: 'apply_preset', args: { asset: line.id, preset: 'organ' } }, ]); expect(wrong.text).toContain('Line is no instrument'); const unknown = await run(fixture, [ { command: 'apply_preset', args: { asset: bass.id, preset: 'kazoo' } }, ]); expect(unknown.result.isError).toBe(true); }); }); describe('usedBy', () => { it('names every asset naming it or a part of it, with the objects, nodes and items in there', async () => { const fixture = makeSpriteScene(); const done = await run(fixture, [ { command: 'add_file_asset', args: { type: 'texture', file: PICTURE.file }, as: 'sheet' }, { command: 'add_frame', args: { asset: '$sheet', x: 0, y: 0, w: 16, h: 16 }, as: 'frame' }, { command: 'set_asset_property', args: { asset: fixture.spriteId, key: 'texture', value: '$frame' } }, { command: 'place_asset', args: { in: fixture.sceneId, asset: fixture.spriteId, x: 0, y: 0 }, as: 'box', }, { command: 'set_start_scene', args: { scene: fixture.sceneId } }, { command: 'add_file_asset', args: { type: 'sound', file: BLIP.file }, as: 'blip' }, { command: 'create_asset', args: { type: 'instrument', name: 'Keys' }, as: 'keys' }, { command: 'create_asset', args: { type: 'pattern', name: 'Riff' }, as: 'riff' }, { command: 'create_asset', args: { type: 'song', name: 'Theme' }, as: 'theme' }, { command: 'create_asset', args: { type: 'sprite', name: 'Unused' }, as: 'unused' }, ]); expect(done.result.isError).toBeUndefined(); const id = (name: string) => named(fixture, name).id; const keys = named(fixture, 'Keys') as InstrumentAsset; const sample = keys.graph.nodes.find((n) => n.type === 'InstrumentSample')!; const theme = fixture.project.assets.find((a) => a.id === id('Theme')) as { tracks: { id: string }[] }; const placed = await run(fixture, [ { command: 'set_node_param', args: { asset: keys.id, node: sample.id, key: 'sound', value: id('blip') }, }, { command: 'set_asset_property', args: { asset: id('Riff'), key: 'instrument', value: keys.id } }, { command: 'add_song_item', args: { asset: id('Theme'), track: theme.tracks[0]!.id, play: id('Riff'), tick: 0 }, as: 'item', }, ]); expect(placed.result.isError).toBeUndefined(); const item = (fixture.project.assets.find((a) => a.id === id('Theme')) as { items: { id: string }[] }) .items[0]!.id; const box = fixture.sceneOf().objects.find((o) => o.assetId === fixture.spriteId)!.id; // The sprite wears a frame of the sheet: a use of the sheet, on the sprite itself. expect((await read(fixture, id('hero-idle'))).usedBy).toEqual([ { id: fixture.spriteId, name: 'Box', type: 'sprite' }, ]); expect((await read(fixture, fixture.spriteId)).usedBy).toEqual([ { id: fixture.sceneId, name: 'Level', type: 'scene', at: [{ id: box }] }, ]); expect((await read(fixture, fixture.sceneId)).usedBy).toEqual([{ project: 'startScene' }]); expect((await read(fixture, id('blip'))).usedBy).toEqual([ expect.objectContaining({ id: keys.id, type: 'instrument', at: [{ id: sample.id, type: 'InstrumentSample' }], }), ]); expect((await read(fixture, id('Riff'))).usedBy).toEqual([ { id: id('Theme'), name: 'Theme', type: 'song', at: [expect.objectContaining({ id: item, kind: 'pattern' })], }, ]); expect((await read(fixture, keys.id)).usedBy).toEqual([ { id: id('Riff'), name: 'Riff', type: 'pattern' }, ]); // The Master routing has a node for every song, joined to the output; that alone is no use of it. settleRouting(fixture.project); expect(fixture.project.routing.edges.some((edge) => edge.from.node === id('Theme'))).toBe(true); expect((await read(fixture, id('Theme'))).usedBy).toEqual([]); expect((await read(fixture, id('Unused'))).usedBy).toEqual([]); }); });