/** * What a song keeps to itself (#355): an asset with the song as its owner, left out of every list, * with one user in the song. Split, duplicate and paste give a place a copy of its own; what nothing * names goes in the same step, and comes back with an undo. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand, DeleteAssetCommand, DuplicateAssetCommand, MakeProjectAssetCommand, } from '../../src/core/commands/assetCommands'; import { nextAssetName } from '../../src/core/commands/naming'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { Asset, SongAsset } from '../../src/core/model/Asset'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { assetTitle, embeddedCopy, embeddedIn, embeddedUsedBy, isRowHiddenWhenEmbedded, listedAssets, } from '../../src/core/music/embedded'; import { newItem, type SongItem, songSequence } from '../../src/core/music/song'; import { assetRefChoices } from '../../src/core/properties/assetRef'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeProject } from '../helpers'; const FILE = `${'c4'.repeat(32)}.wav`; const BAR = 1920; /** A song "Theme" with a take of its own on an item, and a project sound "Blip". */ function setup() { const f = makeProject(); const made = new CreateAssetCommand('song', 'Theme'); f.stack.execute(made); const songId = made.asset.id; const song = () => findAssetOf(f.project, songId, 'song')!; const blip = new CreateSoundCommand('Blip', FILE); f.stack.execute(blip); const take = new CreateSoundCommand('Take 1', FILE, songId); f.stack.execute(take); const track = song().tracks[0]!.id; const item = newItem({ track, kind: 'sound', asset: take.asset.id, tick: 0, length: 2 * BAR }); f.stack.execute(new SetSongCommand(songId, { items: [item] })); const edit = (items: SongItem[], extra: { spare?: Asset[] } = {}) => f.stack.execute(new SetSongCommand(songId, { items, ...extra })); return { f, songId, song, track, blipId: blip.asset.id, takeId: take.asset.id, itemId: item.id, edit }; } const ids = (assets: readonly Asset[]) => assets.map((a) => a.id); describe('an embedded asset (#355)', () => { it('is found by its id but left out of every list and of the names', () => { const { f, songId, takeId, blipId, song } = setup(); expect(findAsset(f.project, takeId)?.owner).toBe(songId); expect(ids(listedAssets(f.project))).not.toContain(takeId); expect(ids(assetRefChoices(f.project, ['sound']))).toEqual([blipId]); expect(nextAssetName(f.project, 'Take 1')).toBe('Take 1'); expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeId]); expect(assetTitle(f.project, findAsset(f.project, takeId)!)).toBe('Theme › Take 1'); }); it('hides the rows that do nothing in a song, only while embedded', () => { const { f, takeId, blipId } = setup(); const take = findAsset(f.project, takeId)!; expect(['loop', 'bus', 'reverse', 'fx'].map((k) => isRowHiddenWhenEmbedded(take, k))).toEqual([ true, true, false, false, ]); expect(isRowHiddenWhenEmbedded(findAsset(f.project, blipId)!, 'loop')).toBe(false); }); it('gives the right half of a split a copy of its own, the same one again on redo', () => { const { f, song, takeId, edit } = setup(); const left = song().items[0]!; edit([ { ...left, length: BAR }, { ...left, id: 'right', tick: BAR, length: BAR }, ]); const [a, b] = song().items; expect(a!.asset).toBe(takeId); expect(b!.asset).not.toBe(takeId); const copy = findAsset(f.project, b!.asset)!; expect([copy.owner, copy.name, copy.type]).toEqual([song().id, 'Take 1', 'sound']); f.stack.undo(); expect(findAsset(f.project, copy.id)).toBeUndefined(); expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]); f.stack.redo(); expect(song().items[1]!.asset).toBe(copy.id); expect(findAsset(f.project, copy.id)).toBe(copy); }); it('goes with the item that names it, and comes back on undo where it was', () => { const { f, songId, takeId, edit } = setup(); const at = f.project.assets.findIndex((a) => a.id === takeId); edit([]); expect(findAsset(f.project, takeId)).toBeUndefined(); f.stack.undo(); expect(f.project.assets.findIndex((a) => a.id === takeId)).toBe(at); expect(findAsset(f.project, takeId)?.owner).toBe(songId); }); it('comes back from what was copied along when a paste names it after it went', () => { const { f, song, takeId, edit } = setup(); const copied = song().items.map((i) => ({ ...i })); const spare = structuredClone(embeddedUsedBy(f.project, copied)); expect(ids(spare)).toEqual([takeId]); edit([]); edit( copied.map((i) => ({ ...i, id: 'pasted' })), { spare }, ); expect(song().items[0]!.asset).toBe(takeId); expect(findAsset(f.project, takeId)?.owner).toBe(song().id); }); it('is copied for a song that names one of another song', () => { const { f, song, takeId } = setup(); const other = new CreateAssetCommand('song', 'Other'); f.stack.execute(other); const otherSong = other.asset as SongAsset; const item = newItem({ track: otherSong.tracks[0]!.id, kind: 'sound', asset: takeId, tick: 0, length: BAR, }); f.stack.execute(new SetSongCommand(otherSong.id, { items: [item] })); const named = findAssetOf(f.project, otherSong.id, 'song')!.items[0]!.asset; expect(named).not.toBe(takeId); expect(findAsset(f.project, named)?.owner).toBe(otherSong.id); expect(findAsset(f.project, takeId)?.owner).toBe(song().id); }); it('leaves a project asset named by several items alone', () => { const { f, song, blipId, edit, track } = setup(); const before = f.project.assets.length; edit([ newItem({ track, kind: 'sound', asset: blipId, tick: 0, length: BAR }), newItem({ track, kind: 'sound', asset: blipId, tick: BAR, length: BAR }), ]); expect(song().items.map((i) => i.asset)).toEqual([blipId, blipId]); expect(f.project.assets.length).toBe(before - 1); }); it('is embedded as a copy of a project asset, which stays', () => { const { f, song, blipId, edit, track } = setup(); const copy = embeddedCopy(findAsset(f.project, blipId)!, song().id); edit([newItem({ track, kind: 'sound', asset: copy.id, tick: 0, length: BAR })], { spare: [copy] }); expect(song().items[0]!.asset).toBe(copy.id); expect(findAsset(f.project, copy.id)?.owner).toBe(song().id); expect(findAsset(f.project, blipId)?.owner).toBeUndefined(); }); it('goes with its song, and comes back with it', () => { const { f, songId, takeId } = setup(); f.stack.execute(new DeleteAssetCommand(songId)); expect(findAsset(f.project, takeId)).toBeUndefined(); f.stack.undo(); expect(findAsset(f.project, takeId)?.owner).toBe(songId); expect(findAsset(f.project, songId)).toBeDefined(); }); it('is copied for a duplicate of its song, which names the copy', () => { const { f, songId, takeId } = setup(); const dup = new DuplicateAssetCommand(songId); f.stack.execute(dup); const copy = findAssetOf(f.project, dup.copyId, 'song')!; const named = copy.items[0]!.asset; expect(named).not.toBe(takeId); expect(findAsset(f.project, named)?.owner).toBe(copy.id); expect(embeddedIn(f.project, songId).map((a) => a.id)).toEqual([takeId]); f.stack.undo(); expect(findAsset(f.project, named)).toBeUndefined(); }); it('becomes a project asset of a free name, and back on undo', () => { const { f, songId, takeId } = setup(); f.stack.execute(new CreateSoundCommand('Take 1', FILE)); f.stack.execute(new MakeProjectAssetCommand(takeId)); const take = findAsset(f.project, takeId)!; expect([take.owner, take.name]).toEqual([undefined, 'Take 1 2']); expect(ids(listedAssets(f.project))).toContain(takeId); f.stack.undo(); expect([take.owner, take.name]).toEqual([songId, 'Take 1']); }); it('a duplicate of it is a project asset', () => { const { f, takeId } = setup(); const dup = new DuplicateAssetCommand(takeId); f.stack.execute(dup); expect(findAsset(f.project, dup.copyId)?.owner).toBeUndefined(); }); it('keeps its owner through a save, and is a project asset once its song is gone', () => { const { f, songId, takeId } = setup(); const raw = serializeProject(f.project) as { assets: Record[] }; expect(deserializeProject(raw).assets.find((a) => a.id === takeId)?.owner).toBe(songId); const orphan = { ...raw, assets: raw.assets.filter((a) => a.id !== songId) }; expect(deserializeProject(orphan).assets.find((a) => a.id === takeId)?.owner).toBeUndefined(); const sprite = { ...raw, assets: [...raw.assets, { id: 'sp', type: 'sprite', name: 'S', props: {}, owner: songId }], }; expect(deserializeProject(sprite).assets.find((a) => a.id === 'sp')?.owner).toBeUndefined(); }); });