/** * What a song keeps to itself (#355): an asset with the song as its owner, left out of every list, * shared by the places of the song (#357). A paste from another song gives it 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, ResetAssetPropertyCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { nextAssetName } from '../../src/core/commands/naming'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { Asset, PatternAsset, SongAsset } from '../../src/core/model/Asset'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { assetTitle, embeddedCopy, embeddedIn, embeddedName, embeddedUsedBy, instrumentNear, isRowHiddenWhenEmbedded, listedAssets, songClockProps, } from '../../src/core/music/embedded'; import { patternLength, patternMeter, patternTempo } from '../../src/core/music/pattern'; 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('is shared by both halves of a split (#357), and stays until the last place lets go', () => { const { f, song, takeId, edit } = setup(); const before = f.project.assets.length; const left = song().items[0]!; edit([ { ...left, length: BAR }, { ...left, id: 'right', tick: BAR, length: BAR }, ]); expect(song().items.map((i) => i.asset)).toEqual([takeId, takeId]); expect(f.project.assets.length).toBe(before); edit([song().items[1]!]); expect(findAsset(f.project, takeId)?.owner).toBe(song().id); edit([]); expect(findAsset(f.project, takeId)).toBeUndefined(); f.stack.undo(); expect(embeddedIn(f.project, song().id).map((x) => x.id)).toEqual([takeId]); }); it('is shared by the song, its tracks and its items, whichever kind (#357)', () => { const { f, song, songId, track, edit } = setup(); const fx = new CreateAssetCommand('soundfx', 'Sound FX 1', {}, songId); const pattern = new CreateAssetCommand('pattern', 'Pattern 1', {}, songId); f.stack.execute(fx); f.stack.execute(pattern); const tracks = song().tracks.map((t) => ({ ...t, fx: fx.asset.id })); const played = { track, kind: 'pattern' as const, asset: pattern.asset.id, length: BAR }; const items = [0, BAR].map((tick) => ({ ...newItem({ ...played, tick }), fx: fx.asset.id })); f.stack.execute(new SetSongCommand(songId, { tracks, items })); f.stack.execute(new SetAssetPropertyCommand(songId, 'fx', fx.asset.id)); expect(song().items.map((i) => [i.asset, i.fx])).toEqual([ [pattern.asset.id, fx.asset.id], [pattern.asset.id, fx.asset.id], ]); expect(song().tracks[0]!.fx).toBe(fx.asset.id); expect( embeddedIn(f.project, songId) .map((a) => a.type) .sort(), ).toEqual(['pattern', 'soundfx']); f.stack.execute(new SetAssetPropertyCommand(songId, 'fx', '')); edit(song().items.map((i) => ({ ...i, fx: '' }))); expect(findAsset(f.project, fx.asset.id)?.owner).toBe(songId); f.stack.execute(new SetSongCommand(songId, { tracks: song().tracks.map((t) => ({ ...t, fx: '' })) })); expect(findAsset(f.project, fx.asset.id)).toBeUndefined(); }); 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('is copied once for all the items of another song that name it (#357)', () => { const { f, takeId } = setup(); const other = new CreateAssetCommand('song', 'Other'); f.stack.execute(other); const otherSong = other.asset as SongAsset; const track = otherSong.tracks[0]!.id; const pasted = [0, BAR].map((tick) => newItem({ track, kind: 'sound', asset: takeId, tick, length: BAR }), ); f.stack.execute(new SetSongCommand(otherSong.id, { items: pasted })); const named = findAssetOf(f.project, otherSong.id, 'song')!.items.map((i) => i.asset); expect(named[0]).not.toBe(takeId); expect(named[1]).toBe(named[0]); expect(embeddedIn(f.project, otherSong.id).map((a) => a.id)).toEqual([named[0]]); }); 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()); 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(); }); }); describe('a pattern made in a song (#355)', () => { /** The song of `setup` at 140 BPM in 3/4, with a project pattern "Loose" and two instruments. */ function withPatterns() { const s = setup(); const { f, songId } = s; f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 140)); f.stack.execute(new SetAssetPropertyCommand(songId, 'beats', 3)); const loose = new CreateAssetCommand('pattern', 'Loose'); f.stack.execute(loose); const lead = new CreateAssetCommand('instrument', 'Lead'); const bass = new CreateAssetCommand('instrument', 'Bass'); f.stack.execute(lead); f.stack.execute(bass); const make = (props: Record = {}) => { const made = new CreateAssetCommand( 'pattern', embeddedName(f.project, songId, 'Pattern'), { ...songClockProps(s.song().props), bars: 1, ...props }, songId, ); f.stack.execute(made); return made.asset as PatternAsset; }; return { ...s, loose: loose.asset as PatternAsset, leadId: lead.asset.id, bassId: bass.asset.id, make }; } it('is a bar of the song on its clock, numbered among what the song keeps', () => { const { f, songId, make } = withPatterns(); f.stack.execute(new CreateAssetCommand('pattern', 'Pattern 1')); const first = make(); expect([first.name, first.owner, patternTempo(first.props)]).toEqual(['Pattern 1', songId, 140]); expect(patternMeter(first.props)).toEqual({ beats: 3, unit: 4 }); expect(patternLength(first)).toBe(3 * 480); expect(make().name).toBe('Pattern 2'); }); it('follows the tempo and the meter of its song in the same undo step, and nothing else', () => { const { f, songId, loose, make } = withPatterns(); const own = make(); f.stack.execute(new SetAssetPropertyCommand(songId, 'tempo', 90)); expect([patternTempo(own.props), patternTempo(loose.props)]).toEqual([90, 120]); f.stack.execute(new SetAssetPropertyCommand(songId, 'beat', '8')); expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 }); f.stack.execute(new ResetAssetPropertyCommand(songId, 'beats')); expect(patternMeter(own.props).beats).toBe(4); const grid = own.props.grid; f.stack.execute(new SetAssetPropertyCommand(songId, 'grid', '1/8')); expect([own.props.grid, findAsset(f.project, songId)?.props.grid]).toEqual([grid, '1/8']); f.stack.undo(); f.stack.undo(); expect(patternMeter(own.props)).toEqual({ beats: 3, unit: 8 }); f.stack.undo(); f.stack.undo(); expect([patternTempo(own.props), patternMeter(own.props).unit]).toEqual([140, 4]); }); it('hides its clock and its bus, not its length, instrument or sound FX', () => { const { loose, make } = withPatterns(); const own = make(); const keys = ['tempo', 'beats', 'beat', 'bus', 'bars', 'instrument', 'fx']; expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([ true, true, true, true, false, false, false, ]); expect(isRowHiddenWhenEmbedded(loose, 'tempo')).toBe(false); }); it('plays the instrument of the pattern on its track that starts last before it, else the first after', () => { const { f, track, edit, song, takeId, leadId, bassId, make } = withPatterns(); const withLead = make({ instrument: leadId }); const other = make(); const items = [ ...song().items, newItem({ track, kind: 'pattern', asset: withLead.id, tick: BAR, length: BAR }), { ...newItem({ track, kind: 'pattern', asset: other.id, tick: 3 * BAR, length: BAR }), instrument: bassId, }, newItem({ track, kind: 'sound', asset: takeId, tick: 4 * BAR, length: BAR }), ]; edit(items); const near = (tick: number, on = track) => instrumentNear(f.project, song().items, on, tick); expect([near(0), near(2 * BAR), near(3 * BAR), near(5 * BAR)]).toEqual([leadId, leadId, bassId, bassId]); expect(near(0, 'elsewhere')).toBe(''); }); it("takes the clock of the song it comes to as a copy, a project pattern's or another song's", () => { const { f, song, loose, track, edit } = withPatterns(); const copy = embeddedCopy(loose, song()).copy as PatternAsset; expect([patternTempo(copy.props), patternTempo(loose.props)]).toEqual([140, 120]); const other = new CreateAssetCommand('song', 'Other'); f.stack.execute(other); const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1 }, other.asset.id); f.stack.execute(theirs); edit([ ...song().items, newItem({ track, kind: 'pattern', asset: theirs.asset.id, tick: 0, length: BAR }), ]); const named = findAssetOf(f.project, song().items.at(-1)!.asset, 'pattern')!; expect(named.id).not.toBe(theirs.asset.id); expect([named.owner, patternTempo(named.props), patternMeter(named.props).beats]).toEqual([ song().id, 140, 3, ]); }); });