A pattern for one song had to be made in the Assets panel first and dragged in, and then stood in every picker of the project. Now a right-click on an empty spot of a track makes one the song keeps to itself, on top of the embedded assets of the first part. - Create pattern lays an item from the grid line before the spot, a bar long, and opens the pattern in its tab. Pattern, item and name are one undo step. The name is "Pattern 1", 2, … per song. - It plays the instrument of the track's pattern item that starts last before the spot, else of the first one after it. An item's own pick counts before its pattern's. - An embedded pattern runs on the song's clock. It takes the tempo and the meter of the song, and a change on the song moves it along in the same undo step (`AssetPropertyCommand`). - Its rows Tempo, Beats per bar, Beat and Bus are hidden, as the song decides them. Length, grid, instrument and sound FX stay. - A copy that comes to a song (Embed a copy, a paste from another song) takes that song's clock. Made a project asset, it keeps the values it had. - `CreateAssetCommand` takes an owner. `embeddedCopy` takes the song now, not its id. - The cleanup of e2e 65 also takes what the song keeps. Tests: - Unit embedded: 18 green (5 new), 6 counter-checks each red - Unit take, song, pattern and the asset commands: green - e2e 65 (melektaus-atx): ALL PASSED, 49 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4172 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
14 KiB
TypeScript
333 lines
14 KiB
TypeScript
/**
|
||
* 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,
|
||
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,
|
||
embeddedPatternName,
|
||
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('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());
|
||
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<string, unknown>[] };
|
||
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<string, string | number> = {}) => {
|
||
const made = new CreateAssetCommand(
|
||
'pattern',
|
||
embeddedPatternName(f.project, songId),
|
||
{ ...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()) 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,
|
||
]);
|
||
});
|
||
});
|