A reference search over every export and method in src/ found code that only tests still called, or that was never called at all: - RemoveSceneObjectCommand: the product only removes through the multi-object command; the tests now use that one too. - NodeRegistry.requireNodeDef: a second function of the same name in commands/require.ts raises a CommandError instead of throwing, and is the one the product uses. The twin invited the wrong import. - isTextureAsset, listContainers, AssetTabEditor.show, FocusManager.destroy, KeyboardBridge.selectAll and the getters ColorField.value, ListView.selected, NumberField.number. Also: Theme.controlActive now matches the resting control colour, and a new note asks what editing the graph during play would take. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
500 lines
23 KiB
TypeScript
500 lines
23 KiB
TypeScript
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();
|
||
});
|
||
});
|
||
|
||
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()
|
||
});
|
||
});
|