Files
feuerwerk/tests/core/embedded.test.ts
T
adminandClaude Opus 5.5 668682c4ff Give a song, a track or an item a sound FX of its own (#355)
A sound FX for one place in a song had to be a project asset, which
then stood in every picker. Now every pick of a sound FX in a song
can make one the song keeps for that place alone, on top of the
embedded assets of the first two parts.

- Create sound FX sits at the end of the Sound FX row of the song,
  a track and an item, and of the menu of a track's FX button. It
  makes "Sound FX 1", 2, … per song with the Input wired to the
  Output, picks it and opens it. That is one undo step.
- Embed a copy, while the place names a project FX, gives the place
  a copy under the same name.
- While the place names its own, the menu shows it with the mark and
  offers Edit sound FX and Make project asset.
- Picking anything else lets it go in the same undo step. For a
  track or an item `settleEmbedded` did that already. For the song's
  own row `AssetPropertyCommand` now settles the song after `fx`.
- The mark `•` moved to the core. `assetRefLabel` puts it in front of
  an embedded asset, so the inspector rows show it like the timeline.
- `FieldHandlers.moreChoices` lets a caller end a reference menu
  with entries of its own. `embeddedPatternName` is `embeddedName`
  with a base now.

Tests:
- Unit own-fx (new): 6 green, 6 counter-checks each red
- Unit embedded and 80 neighbouring files: 1434 green
- e2e 65 (melektaus-atx): ALL PASSED, 51 checks (2 new)
- Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:32:41 +02:00

333 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
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('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',
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()) 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,
]);
});
});