A behavior object carries a "Run in editor" switch next to Enabled. When it is on, the behavior ticks outside of Play and works on the saved model directly - Unity semantics: what it moves is a real change, autosaved and not undoable. Input stays with the editor, so On key never fires while typing. The editor run never bakes placed containers into the model, since that would silently drop their instanceEdits. syncToModel reports whether it wrote anything, and the dirty flag is only raised on change - otherwise a behavior moving something every frame would trigger ~60 saves a second.
501 lines
23 KiB
TypeScript
501 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,
|
||
RemoveSceneObjectCommand,
|
||
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 RemoveSceneObjectCommand(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 RemoveSceneObjectCommand(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 RemoveSceneObjectCommand(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()
|
||
});
|
||
});
|