Files
adminandClaude Opus 5.5 26cc6324af Stop resolving every object in every frame of a run (#287)
World.syncToModel runs after every frame of a run and resolved each
object of the scene anew to compare it with its entity. With 222
entities that was most of the Editor line in the performance panel,
also for entities that are never drawn.

- The world keeps per entity what the model said when it last read
  the object (syncFromModel, spawn) or wrote it. A frame compares the
  entity with that and resolves nothing while both agree.
- A place that differs is written as three numbers.
- A value that differs is written as an override, and that one object
  is resolved again. An override can bring rows of its own, such as a
  sprite's shader.
- The objects of entities that left the run are dropped only in a
  frame in which one left. World.remove notes that.
- Editing during a run works as before: the editor has the model read
  again on every project event, which renews what the world keeps.

One difference: a write to the model that goes past the editor and
does not have it read again now stays. The next frame used to
overwrite it with the entity's state. No code does that, during a run
only the world writes.

Measured on stein2 with a copy of the reference project of #284, ms
per frame: syncToModel alone 0.92 to 0.12 (Node, 600 frames), the
Editor line in the browser 1.14 / 1.20 / 1.29 to 0.25 / 0.25 / 0.27.
Physics and Drawing are unchanged.

Tests:
- Gate: 198 files, 2891 tests (2887 + 4), exit 0
- e2e, full run: 1613 checks passed, exit 0. That measures the total
  that was only computed since #273.
- Smokes: 24 / 19 / 21, exit 0
- Four new unit tests, no new e2e check: 60 unchanged frames resolve
  nothing, an edit during the run arrives and stays, an override with
  rows of its own is seen with them, container content in an editor
  run is written into the instance edits once
- Seven counter-probes fail, 1 to 4 tests each

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:27:54 +02:00

525 lines
24 KiB
TypeScript
Raw Permalink 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.
import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
import { CreateContainerCommand, UnpackContainerCommand } from '../../src/core/commands/containerCommands';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
RemoveSceneObjectsCommand,
ReorderObjectCommand,
ClearOverrideCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { worldPositionOf } from '../../src/core/graph';
import type { ContainerAsset } from '../../src/core/model/Asset';
import { containerReaches } from '../../src/core/model/container';
import { descendantsOf, subtreeRoots, treeOrder } from '../../src/core/model/hierarchy';
import { instanceChildId } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project';
import { createSceneObject } from '../../src/core/model/SceneObject';
import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
/** Three boxes: a at (100,100,z1), b at (10,0) and c at (0,5) parented under b (local (-10,5)). */
function setup() {
const f = makeSpriteScene();
const a = place(f, f.spriteId, { x: 100, y: 100, z: 1 });
const b = place(f, f.spriteId, { x: 10, y: 0 });
const c = place(f, f.spriteId, { x: 0, y: 5 });
f.stack.execute(new SetParentCommand(f.sceneId, c, b));
return { ...f, a, b, c };
}
/**
* The container asset built from a, b and c – which leaves the scene holding the container object
* alone. `child` addresses the content the instance resolves; `tA`/`tB`/`tC` are the template
* objects it comes from, in the order `CreateContainerCommand` built them (tree order).
*/
function templateSetup() {
const f = setup();
const create = new CreateContainerCommand(f.sceneId, [f.a, f.b]);
f.stack.execute(create);
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
const [tA, tB, tC] = asset().objects.map((t) => t.id) as [string, string, string];
return {
...f,
containerId: create.assetId,
instanceId: create.objectId,
asset,
tA,
tB,
tC,
template: (id: string) => asset().objects.find((t) => t.id === id)!,
child: (templateId: string, instanceId = create.objectId) => instanceChildId(instanceId, templateId),
};
}
/** Three container assets holding one box each, none of them nested yet. */
function makeContainers() {
const f = makeSpriteScene();
const build = () => {
const command = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
f.stack.execute(command);
return command.assetId;
};
const assetOf = (id: string) => findAsset(f.project, id) as ContainerAsset;
return { ...f, outerId: build(), innerId: build(), deepId: build(), assetOf };
}
describe('subtree roots', () => {
it('drops ids that have a selected ancestor', () => {
const { sceneOf, a, b, c } = setup();
expect(subtreeRoots(sceneOf(), [a, b, c])).toEqual([a, b]);
expect(subtreeRoots(sceneOf(), [c, a])).toEqual([c, a]);
});
});
describe('CreateContainerCommand', () => {
it('centres the template on the selection and shows it again through the placed container', () => {
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, viewOf, object, world } = setup();
const before = { a: world(a), b: world(b), c: world(c) };
const command = new CreateContainerCommand(sceneId, [a, b]);
stack.execute(command);
// Bounding box of (100,100) and (10,0) → centre (55,50); z is always 0.
const container = object(command.objectId);
expect(container.position).toEqual({ x: 55, y: 50, z: 0 });
// The members are gone from the scene: the container object is all that is stored.
expect(sceneOf().objects.map((o) => o.id)).toEqual([command.objectId]);
const asset = findAsset(project, command.assetId) as ContainerAsset;
expect(asset.type).toBe('container');
expect(asset.objects).toHaveLength(3);
const [tA, tB, tC] = asset.objects;
expect(tA!.position).toEqual({ x: 45, y: 50, z: 1 });
expect(tA!.parentId).toBe('');
expect(tC!.parentId).toBe(tB!.id);
expect(tC!.assetId).toBe(spriteId);
expect(asset.objects.map((t) => t.id)).not.toContain(a);
// What the editor lists: the container plus the content resolved from the template, and the
// three objects still stand exactly where they did.
const ids = treeOrder(viewOf()).map((e) => e.object.id);
expect(ids).toEqual([
command.objectId,
instanceChildId(command.objectId, tA!.id),
instanceChildId(command.objectId, tB!.id),
instanceChildId(command.objectId, tC!.id),
]);
expect(world(ids[1]!)).toEqual(before.a);
expect(world(ids[2]!)).toEqual(before.b);
expect(world(ids[3]!)).toEqual(before.c);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
expect(object(a).parentId).toBe('');
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
expect(project.assets.some((x) => x.id === command.assetId)).toBe(false);
stack.redo();
expect(world(instanceChildId(command.objectId, tA!.id))).toEqual(before.a);
});
it('places the container below the parent all roots share', () => {
const f = setup();
const { project, stack, sceneId, spriteId, b, c, object, world } = f;
const d = place(f, spriteId, { x: 30, y: 25 }, b);
const command = new CreateContainerCommand(sceneId, [c, d]);
stack.execute(command);
const container = object(command.objectId);
expect(container.parentId).toBe(b);
// World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
const asset = findAsset(project, command.assetId) as ContainerAsset;
const [tc, td] = asset.objects.map((t) => instanceChildId(command.objectId, t.id));
expect(world(tc!)).toEqual({ x: 0, y: 5, z: 0 });
expect(world(td!)).toEqual({ x: 30, y: 25, z: 0 });
});
it('rejects an empty or unknown selection', () => {
const { stack, sceneId } = setup();
expect(() => stack.execute(new CreateContainerCommand(sceneId, []))).toThrow(/at least one/);
expect(() => stack.execute(new CreateContainerCommand(sceneId, ['nope']))).toThrow(/not found/);
});
});
describe('placing a container', () => {
it('stores one object and resolves the template below it', () => {
const f = templateSetup();
const { stack, sceneOf, viewOf, world, containerId, tA, tB, tC } = f;
const stored = sceneOf().objects.length;
const placed = place(f, containerId, { x: 1000, y: 0 });
expect(sceneOf().objects).toHaveLength(stored + 1);
expect(descendantsOf(viewOf(), placed).map((o) => o.id)).toEqual([
instanceChildId(placed, tA),
instanceChildId(placed, tB),
instanceChildId(placed, tC),
]);
expect(world(instanceChildId(placed, tA))).toEqual({ x: 1045, y: 50, z: 1 });
expect(world(instanceChildId(placed, tC))).toEqual({ x: 945, y: -45, z: 0 });
stack.undo();
expect(sceneOf().objects).toHaveLength(stored);
expect(viewOf().objects.some((o) => o.id === placed)).toBe(false);
});
it('survives a save/load round trip, instance edits included', () => {
const f = templateSetup();
const { project, stack, sceneId, containerId, instanceId, tA } = f;
stack.execute(new SetOverrideCommand(sceneId, f.child(tA), 'width', 12));
const loaded = roundTrip(project);
const asset = findAsset(loaded, containerId) as ContainerAsset;
expect(asset.objects).toHaveLength(3);
const scene = findScene(loaded, sceneId)!;
const instance = scene.objects.find((o) => o.id === instanceId)!;
expect(instance.assetId).toBe(containerId);
expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
});
it('deleting an asset removes its template objects, and the content with them', () => {
const f = templateSetup();
const { project, stack, spriteId, containerId, asset, viewOf } = f;
stack.execute(new DeleteAssetCommand(spriteId));
expect(asset().objects).toHaveLength(0);
expect(viewOf().objects).toHaveLength(1); // the container object, now empty
stack.undo();
expect(asset().objects).toHaveLength(3);
expect(viewOf().objects).toHaveLength(4);
expect(findAsset(project, containerId)).toBeDefined();
});
it('spawns a container with its content at runtime', () => {
const f = templateSetup();
const { world } = startWorld(f);
const spawned = world.spawn(f.containerId, { x: 500, y: 500, z: 0 }, null)!;
expect(spawned.type).toBe('container');
const children = world.entities.filter((e) => e.parent === spawned);
expect(children).toHaveLength(2);
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
expect(worldPositionOf(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
expect(worldPositionOf(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
expect(world.syncToModel().structure).toBe(true);
// Play bakes the placed container into stored objects, and the spawn adds a second set.
expect(f.sceneOf().objects).toHaveLength(8);
});
it('leaves the instance alone when baking is off, so the editor run cannot spend it', () => {
const f = templateSetup();
f.stack.execute(new SetOverrideCommand(f.sceneId, f.child(f.tA), 'width', 12));
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]?.overrides.width).toBe(12);
// Baking would write the content out as plain objects and drop the edits – a real, silent
// change to the saved project, which is why the editor run instantiates without it.
const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
world.tick(1 / 60);
expect(world.syncToModel().structure).toBe(false);
expect(f.sceneOf().objects).toHaveLength(1);
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined();
});
it('writes what a run changes of unbaked content into the instance edits, once (#287)', () => {
const f = templateSetup();
const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
const instance = () => f.sceneOf().objects[0]!;
const content = world.findByObjectId(f.child(f.tA))!;
const home = { x: content.x, y: content.y };
content.x = home.x + 7;
content.props.width = 12;
expect(world.syncToModel()).toEqual({ structure: false, values: true });
expect(instance().instanceEdits[f.tA]).toMatchObject({
position: { x: home.x + 7 },
overrides: { width: 12 },
});
expect(world.syncToModel()).toEqual({ structure: false, values: false });
// Back at the template's place the edit of the place is gone, and the next frame has nothing to say.
content.x = home.x;
expect(world.syncToModel().values).toBe(true);
expect(instance().instanceEdits[f.tA]).toEqual({ position: {}, overrides: { width: 12 } });
expect(world.syncToModel().values).toBe(false);
world.syncFromModel();
expect({ x: content.x, y: content.y, width: content.props.width }).toEqual({ ...home, width: 12 });
});
});
describe('editing a container template', () => {
it('reaches the content of every placed instance', () => {
const f = templateSetup();
const { stack, containerId, tA, template, object, child } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
stack.execute(new RenameSceneObjectCommand(containerId, tA, 'Hero'));
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(template(tA).position).toEqual({ x: 7, y: 8, z: 9 });
for (const instance of [f.instanceId, second]) {
expect(object(child(tA, instance)).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(child(tA, instance)).name).toBe('Hero');
expect(object(child(tA, instance)).overrides.width).toBe(55);
}
stack.execute(new RemoveSceneObjectsCommand(containerId, [tA]));
expect(template(tA)).toBeUndefined();
expect(f.viewOf().objects.some((o) => o.id === child(tA, second))).toBe(false);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBe(55);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBeUndefined();
stack.undo();
expect(object(child(tA, second)).name).toBe('');
stack.undo();
expect(object(child(tA, second)).position).toEqual({ x: 45, y: 50, z: 1 });
});
it('restores the template order when a removal is undone', () => {
const { stack, containerId, asset, tA, tB, tC } = templateSetup();
expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
stack.execute(new RemoveSceneObjectsCommand(containerId, [tB]));
expect(asset().objects.map((t) => t.id)).toEqual([tA]);
stack.undo();
expect(asset().objects.map((t) => t.id)).toEqual([tA, tB, tC]);
expect(asset().objects[2]!.parentId).toBe(tB);
});
it('rejects an object id that belongs to the scene rather than to the template', () => {
const { stack, containerId, instanceId } = templateSetup();
expect(() => stack.execute(new RenameSceneObjectCommand(containerId, instanceId, 'Hero'))).toThrow(
/not found/,
);
});
});
describe('a value changed on one instance', () => {
it('is kept as a sparse edit and stops the value from following', () => {
const f = templateSetup();
const { stack, sceneId, containerId, instanceId, tA, object, child, sceneOf } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA, second)).overrides.width).toBe(55);
expect(object(child(tA)).overrides.width).toBe(12);
const instance = sceneOf().objects.find((o) => o.id === instanceId)!;
expect(instance.instanceEdits[tA]?.overrides.width).toBe(12);
stack.undo();
expect(object(child(tA, second)).overrides.width).toBeUndefined();
expect(object(child(tA)).overrides.width).toBe(12);
});
it('forgets the edit again once the value is put back to the template’s', () => {
const { stack, sceneId, instanceId, tA, child, sceneOf, object } = templateSetup();
const edits = () => sceneOf().objects.find((o) => o.id === instanceId)!.instanceEdits;
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 1, y: 50, z: 1 }));
expect(edits()[tA]?.position).toEqual({ x: 1 });
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA), { x: 45, y: 50, z: 1 }));
expect(edits()[tA]).toBeUndefined();
expect(object(child(tA)).position).toEqual({ x: 45, y: 50, z: 1 });
});
it('follows per axis, so an instance dragged sideways keeps only its x', () => {
const f = templateSetup();
const { stack, sceneId, containerId, tA, object, child } = f;
const second = place(f, containerId, { x: 1000, y: 0 });
stack.execute(new MoveSceneObjectCommand(sceneId, child(tA, second), { x: 999, y: 50, z: 1 }));
stack.execute(new MoveSceneObjectCommand(containerId, tA, { x: 7, y: 8, z: 9 }));
expect(object(child(tA)).position).toEqual({ x: 7, y: 8, z: 9 });
expect(object(child(tA, second)).position).toEqual({ x: 999, y: 8, z: 9 });
stack.undo();
expect(object(child(tA, second)).position).toEqual({ x: 999, y: 50, z: 1 });
});
it('clears an override on every instance that shared it', () => {
const { stack, containerId, tA, object, child } = templateSetup();
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA)).overrides.width).toBe(55);
stack.execute(new ClearOverrideCommand(containerId, tA, 'width'));
expect(object(child(tA)).overrides.width).toBeUndefined();
stack.undo();
expect(object(child(tA)).overrides.width).toBe(55);
});
it('does not leak to the other instances', () => {
const f = templateSetup();
const { stack, sceneId, tA, object, child } = f;
const second = place(f, f.containerId, { x: 1000, y: 0 });
stack.execute(new SetOverrideCommand(sceneId, child(tA, second), 'width', 12));
stack.execute(new RenameSceneObjectCommand(sceneId, child(tA, second), 'Only me'));
expect(object(child(tA)).overrides.width).toBeUndefined();
expect(object(child(tA)).name).toBe('');
});
});
describe('the structure of a placed container', () => {
it('cannot be changed from the scene', () => {
const { stack, sceneId, tA, tB, child } = templateSetup();
expect(() => stack.execute(new RemoveSceneObjectsCommand(sceneId, [child(tA)]))).toThrow(
/comes from a container template/,
);
expect(() => stack.execute(new SetParentCommand(sceneId, child(tA), child(tB)))).toThrow(
/comes from a container template/,
);
expect(() => stack.execute(new CreateContainerCommand(sceneId, [child(tA)]))).toThrow(
/comes from a container template/,
);
// Order is structure too: it comes from the template, so it is changed there.
expect(() => stack.execute(new ReorderObjectCommand(sceneId, child(tA), child(tB)))).toThrow(
/comes from a container template/,
);
});
it('refuses to take an object of the scene as a child', () => {
const f = templateSetup();
const loose = place(f, f.spriteId, { x: 0, y: 0 });
expect(() => f.stack.execute(new SetParentCommand(f.sceneId, loose, f.instanceId))).toThrow(/unpack it/);
});
it('opens up once it is unpacked, and stops following the template', () => {
const f = templateSetup();
const { stack, sceneId, containerId, instanceId, tA, child, sceneOf, object } = f;
stack.execute(new SetOverrideCommand(sceneId, child(tA), 'width', 12));
stack.execute(new UnpackContainerCommand(sceneId, instanceId));
// The ids survive the unpack, so anything pointing at the content keeps working.
expect(sceneOf().objects.map((o) => o.id)).toContain(child(tA));
expect(object(child(tA)).overrides.width).toBe(12);
stack.execute(new SetOverrideCommand(containerId, tA, 'width', 55));
expect(object(child(tA)).overrides.width).toBe(12);
stack.execute(new RemoveSceneObjectsCommand(sceneId, [child(tA)]));
expect(sceneOf().objects.map((o) => o.id)).not.toContain(child(tA));
stack.undo();
stack.undo();
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([instanceId]);
expect(object(child(tA)).overrides.width).toBe(12);
});
});
describe('a container nested in another one', () => {
it('resolves through both levels, and a template edit reaches the deep content', () => {
const f = makeContainers();
const { stack, outerId, innerId, viewOf, assetOf, world } = f;
const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(nest);
const placed = place(f, outerId, { x: 500, y: 0 });
const innerTemplate = assetOf(innerId).objects[0]!;
const deep = instanceChildId(placed, `${nest.objectId}/${innerTemplate.id}`);
expect(descendantsOf(viewOf(), placed)).toHaveLength(3);
expect(world(deep)).toEqual({ x: 520, y: 0, z: 0 });
stack.execute(new RenameSceneObjectCommand(innerId, innerTemplate.id, 'Deep'));
expect(f.object(deep).name).toBe('Deep');
stack.undo();
expect(f.object(deep).name).toBe('');
});
it('keeps a value changed on the deep content out of the template', () => {
const f = makeContainers();
const { stack, sceneId, outerId, innerId, assetOf } = f;
const nest = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(nest);
const placed = place(f, outerId, { x: 500, y: 0 });
const path = `${nest.objectId}/${assetOf(innerId).objects[0]!.id}`;
stack.execute(new SetOverrideCommand(sceneId, instanceChildId(placed, path), 'width', 7));
expect(f.object(instanceChildId(placed, path)).overrides.width).toBe(7);
expect(assetOf(innerId).objects[0]!.overrides.width).toBeUndefined();
expect(f.sceneOf().objects.find((o) => o.id === placed)!.instanceEdits[path]?.overrides.width).toBe(7);
});
});
describe('placing into a container template', () => {
it('creates a single object, without spelling the nested container out', () => {
const { stack, outerId, innerId, assetOf } = makeContainers();
const command = new PlaceAssetCommand(outerId, innerId, { x: 20, y: 0 });
stack.execute(command);
const objects = assetOf(outerId).objects;
expect(objects).toHaveLength(2);
const placed = objects.find((o) => o.id === command.objectId)!;
expect(placed.assetId).toBe(innerId);
expect(placed.position).toEqual({ x: 20, y: 0, z: 0 });
expect(objects.some((o) => o.parentId === command.objectId)).toBe(false);
stack.undo();
expect(assetOf(outerId).objects).toHaveLength(1);
});
it('refuses a placement that would nest a container inside itself', () => {
const { stack, outerId, innerId, deepId, assetOf } = makeContainers();
expect(() => stack.execute(new PlaceAssetCommand(outerId, outerId, { x: 0, y: 0 }))).toThrow(
/nests forever/,
);
expect(assetOf(outerId).objects).toHaveLength(1);
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
expect(() => stack.execute(new PlaceAssetCommand(innerId, outerId, { x: 0, y: 0 }))).toThrow(
CommandError,
);
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
expect(() => stack.execute(new PlaceAssetCommand(deepId, outerId, { x: 0, y: 0 }))).toThrow(CommandError);
expect(assetOf(deepId).objects).toHaveLength(1);
});
});
describe('containerReaches', () => {
it('sees the container itself and its content, transitively', () => {
const { project, stack, outerId, innerId, deepId, spriteId, assetOf } = makeContainers();
expect(containerReaches(project, assetOf(outerId), outerId)).toBe(true);
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(false);
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
stack.execute(new PlaceAssetCommand(innerId, deepId, { x: 0, y: 0 }));
expect(containerReaches(project, assetOf(outerId), innerId)).toBe(true);
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(true);
expect(containerReaches(project, assetOf(deepId), outerId)).toBe(false);
expect(containerReaches(project, assetOf(outerId), spriteId)).toBe(false);
});
it('terminates on a cycle the commands would have refused to create', () => {
const { project, stack, outerId, innerId, deepId, assetOf } = makeContainers();
stack.execute(new PlaceAssetCommand(outerId, innerId, { x: 0, y: 0 }));
assetOf(innerId).objects.push(createSceneObject('cycle', outerId, { x: 0, y: 0 }));
expect(containerReaches(project, assetOf(outerId), deepId)).toBe(false);
expect(containerReaches(project, assetOf(innerId), outerId)).toBe(true);
});
});
describe('multi-selection commands', () => {
it('removes several subtrees as one step, covering nested ids', () => {
const { stack, sceneId, a, b, c, sceneOf, object } = setup();
stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a]));
expect(sceneOf().objects).toHaveLength(0);
stack.undo();
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
expect(object(c).parentId).toBe(b);
});
it('a compound move rolls back entirely when one part fails', () => {
const { stack, sceneId, a, object } = setup();
const compound = new CompoundCommand('Move objects', [
new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }),
new MoveSceneObjectCommand(sceneId, 'missing', { x: 0, y: 0, z: 0 }),
]);
expect(() => stack.execute(compound)).toThrow(/not found/);
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
expect(stack.canUndo).toBe(true); // the earlier commands of setup()
});
});