A project bus could only go into one other bus, and a sound, an
instrument, a pattern or a song into exactly one bus. A shared reverb
needs a sound to go there in part and on dry at the same time. Instead
of a list of sends on every asset, the project gets one routing graph
like the routing of a song (#350), one level up.
- Menu -> Master routing opens a tab with one graph per project. It has
a node per sound, instrument, pattern, song and bus, named after its
asset, and one Master. Embedded assets have none, their song routes
them.
- The nodes follow the assets. Every edit settles the routing in the
same undo step (KeepRoutingCommand), so an undone delete brings the
links of its asset back. A new asset gets a node and a link to the
master. What went only into a deleted bus goes to the master.
- New nodes start in three columns: what plays, the buses, and the
master a row above, so no link into it runs behind a bus.
- Links are sends: -60 to +12 dB, and a Mode on links out of a bus or a
song. Out of a sound, an instrument or a pattern a link always takes
the sound after its volume and FX. Loops are refused.
- A node without a link is silent and says "Goes nowhere".
- The rows Bus and Into are gone. Play nodes with a picked bus still
overrule the routing.
- A click on a node shows its asset in the inspector, the Master shows
the master volume and sound FX. A double click opens the asset's tab.
- The graph lives in project.routing and is held to the assets when a
project is read, but nothing is added there. The editor adds the
nodes when it opens a project. An asset without a node plays on the
master, so old projects sound as before without a migration.
- In the mix an asset with one link at 0 dB goes straight into that bus.
A sound or instrument with more gets a bus of its own with the sends.
A pattern or song sends from the master of its playing, which is
where its Mode taps. Buses of the mix can have sends now too.
Found:
- The check "the scheduler plans a looped bar" in e2e 34 wobbles. It
failed in 2 of 5 runs here and in CI task 580 at 490fcba, before
this change. HEAD passed it 2 of 2. Not fixed here.
Tests:
- Unit master-routing (new, 10), sound-library (2 new), buses
rewritten onto the routing, 5 lists without the Bus row. 5 counter
checks red.
- e2e 34 (melektaus-atx): ALL PASSED, 93 checks (3 new, 2 rewritten).
Counter check with the Sound tab ignoring the routing: the routing
check red.
- Gate (melektaus-atx): CHECK EXIT 0, 273 files, 4225 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
7.1 KiB
TypeScript
183 lines
7.1 KiB
TypeScript
/**
|
|
* Container assets hold placed objects just like scenes do, so anything that walks "all placed
|
|
* objects" has to cover both. Plus: a rejected undo leaves the stacks usable.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { type Command, CommandError } from '../../src/core/commands/Command';
|
|
import { CommandStack } from '../../src/core/commands/CommandStack';
|
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
|
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
|
import type { ContainerAsset } from '../../src/core/model/Asset';
|
|
import { findAsset, findObjectHolder, objectHolders } from '../../src/core/model/Project';
|
|
import { defaultSceneVisibility } from '../../src/core/model/sceneVisibility';
|
|
import { instanceChildId } from '../../src/core/model/instance';
|
|
import { findUsages } from '../../src/core/model/usages';
|
|
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
|
|
|
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
|
|
function containerWithOverride() {
|
|
const f = makeSpriteScene();
|
|
const behavior = makeBehavior(f, 'B', ['speed']);
|
|
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, 'speed', 42));
|
|
const container = new CreateContainerCommand(f.sceneId, [objectId]);
|
|
f.stack.execute(container);
|
|
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
|
|
// The object moved into the template; what the scene shows is resolved from it.
|
|
const placed = () => f.object(instanceChildId(container.objectId, template().id));
|
|
return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template, placed };
|
|
}
|
|
|
|
describe('object holders', () => {
|
|
it('counts scenes and container assets alike', () => {
|
|
const { project } = containerWithOverride();
|
|
expect(
|
|
objectHolders(project)
|
|
.map((h) => h.type)
|
|
.sort(),
|
|
).toEqual(['container', 'scene']);
|
|
});
|
|
|
|
it('resolves a scene or a container by id, but no other asset', () => {
|
|
const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
|
|
expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
|
|
expect(findObjectHolder(project, containerId)?.type).toBe('container');
|
|
expect(findObjectHolder(project, spriteId)).toBeUndefined();
|
|
expect(findObjectHolder(project, behaviorId)).toBeUndefined();
|
|
expect(findObjectHolder(project, 'nope')).toBeUndefined();
|
|
});
|
|
|
|
it('finds usages inside container templates, not just in scenes', () => {
|
|
const { project, behaviorId, containerId, template } = containerWithOverride();
|
|
// Only the template: what the scene shows is resolved from it, never stored.
|
|
const usages = findUsages(project, behaviorId);
|
|
expect(usages).toHaveLength(1);
|
|
expect(usages[0]!.holder.id).toBe(containerId);
|
|
expect(template().overrides.speed).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('RemoveVariableCommand', () => {
|
|
it('clears the override on container templates too, and puts it back on undo', () => {
|
|
const { stack, behaviorId, template, placed } = containerWithOverride();
|
|
|
|
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
|
|
expect(placed().overrides.speed).toBeUndefined();
|
|
expect(template().overrides.speed).toBeUndefined();
|
|
|
|
stack.undo();
|
|
expect(placed().overrides.speed).toBe(42);
|
|
expect(template().overrides.speed).toBe(42);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A behavior inside a placed container is not stored in the scene, and a value changed on it lives
|
|
* in the instance's `instanceEdits`. Anything rewriting overrides project-wide has to reach in
|
|
* there, or renaming a variable silently drops the value.
|
|
*/
|
|
describe('variables overridden on container content', () => {
|
|
function withInstanceEdit() {
|
|
const f = containerWithOverride();
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, f.placed().id, 'speed', 7));
|
|
const edits = () =>
|
|
f.sceneOf().objects.find((o) => o.id !== f.objectId)!.instanceEdits[f.template().id]!.overrides;
|
|
return { ...f, edits };
|
|
}
|
|
|
|
it('renames the key kept on the instance', () => {
|
|
const { stack, behaviorId, edits, placed } = withInstanceEdit();
|
|
expect(edits().speed).toBe(7);
|
|
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
|
|
expect(edits().speed).toBeUndefined();
|
|
expect(edits().velocity).toBe(7);
|
|
expect(placed().overrides.velocity).toBe(7);
|
|
stack.undo();
|
|
expect(edits().speed).toBe(7);
|
|
});
|
|
|
|
it('removes the value kept on the instance, and puts it back on undo', () => {
|
|
const { stack, behaviorId, edits } = withInstanceEdit();
|
|
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
|
|
expect(edits().speed).toBeUndefined();
|
|
stack.undo();
|
|
expect(edits().speed).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('RenameVariableCommand', () => {
|
|
it('renames the override on container templates too', () => {
|
|
const { stack, behaviorId, template } = containerWithOverride();
|
|
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
|
|
expect(template().overrides.speed).toBeUndefined();
|
|
expect(template().overrides.velocity).toBe(42);
|
|
|
|
stack.undo();
|
|
expect(template().overrides.speed).toBe(42);
|
|
expect(template().overrides.velocity).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
class RefusingUndo implements Command {
|
|
readonly label = 'Refusing';
|
|
execute(project: { name: string }): void {
|
|
project.name = 'executed';
|
|
}
|
|
undo(): void {
|
|
throw new CommandError('target is gone');
|
|
}
|
|
}
|
|
|
|
describe('CommandStack', () => {
|
|
it('keeps a command on the undo stack when its undo is rejected', () => {
|
|
const { project, stack } = makeSpriteScene();
|
|
stack.execute(new RefusingUndo());
|
|
expect(stack.canUndo).toBe(true);
|
|
|
|
expect(() => stack.undo()).toThrow(CommandError);
|
|
// Not swallowed by the history: still undoable, and not queued for redo.
|
|
expect(stack.canUndo).toBe(true);
|
|
expect(stack.canRedo).toBe(false);
|
|
expect(project.name).toBe('executed');
|
|
});
|
|
|
|
it('keeps a command on the redo stack when its redo is rejected', () => {
|
|
const project = {
|
|
formatVersion: 1,
|
|
id: 'p',
|
|
name: 'P',
|
|
assets: [],
|
|
activeSceneId: '',
|
|
startSceneId: '',
|
|
plugins: [],
|
|
collisionLayers: ['Default'],
|
|
renderLayers: ['Default'],
|
|
masterVolume: 1,
|
|
masterFx: '',
|
|
routing: { nodes: [], edges: [] },
|
|
symbolScale: 1,
|
|
playFrom: 'start' as const,
|
|
whilePlaying: 'live' as const,
|
|
gameScale: 1,
|
|
gameLinkZoom: 'auto' as const,
|
|
sceneVisibility: defaultSceneVisibility(),
|
|
};
|
|
const stack = new CommandStack(project);
|
|
let failing = false;
|
|
stack.execute({
|
|
label: 'Toggle',
|
|
execute: () => {
|
|
if (failing) throw new CommandError('nope');
|
|
},
|
|
undo: () => {},
|
|
});
|
|
stack.undo();
|
|
failing = true;
|
|
|
|
expect(() => stack.redo()).toThrow(CommandError);
|
|
expect(stack.canRedo).toBe(true);
|
|
expect(stack.canUndo).toBe(false);
|
|
});
|
|
});
|