import { describe, expect, it } from 'vitest'; import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { CompoundCommand } from '../../src/core/commands/CompoundCommand'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { worldPositionOf } from '../../src/core/graph'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { subtreeRoots, treeOrder } from '../../src/core/model/hierarchy'; import { findAsset, findScene } from '../../src/core/model/Project'; 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 }; } 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 re-parents the roots, keeping world positions', () => { const { project, stack, sceneId, spriteId, a, b, c, sceneOf, 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.assetId).toBe(command.assetId); expect(container.parentId).toBe(''); expect(container.position).toEqual({ x: 55, y: 50, z: 0 }); expect(object(a).parentId).toBe(command.objectId); expect(object(a).position).toEqual({ x: 45, y: 50, z: 1 }); expect(object(b).position).toEqual({ x: -45, y: -50, z: 0 }); expect(object(c).parentId).toBe(b); // descendants keep their local frame expect(object(c).position).toEqual({ x: -10, y: 5, z: 0 }); expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before); // The asset holds the same structure with private ids; the members point at it. const asset = findAsset(project, command.assetId) as ContainerAsset; expect(asset.type).toBe('container'); expect(asset.objects).toHaveLength(3); const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!; expect(template(a).position).toEqual({ x: 45, y: 50, z: 1 }); expect(template(a).parentId).toBe(''); expect(template(c).parentId).toBe(template(b).id); expect(template(c).assetId).toBe(spriteId); expect(asset.objects.map((t) => t.id)).not.toContain(a); expect(treeOrder(sceneOf()).map((e) => e.object.id)).toEqual([command.objectId, a, b, 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(object(a).templateId).toBe(''); expect(project.assets.some((x) => x.id === command.assetId)).toBe(false); stack.redo(); expect(object(a).parentId).toBe(command.objectId); expect(world(a)).toEqual(before.a); }); it('places the container below the parent all roots share', () => { const f = setup(); const { 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 }); expect(world(c)).toEqual({ x: 0, y: 5, z: 0 }); expect(world(d)).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('instantiates the template with fresh ids below the placed object', () => { const f = setup(); const { project, stack, sceneId, a, b, c, sceneOf, object, world } = f; const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); const placed = place(f, create.assetId, { x: 1000, y: 0 }); const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id)); expect(copies).toHaveLength(4); // container + 3 template objects const root = object(placed); expect(root.assetId).toBe(create.assetId); const asset = findAsset(project, create.assetId) as ContainerAsset; const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!; expect(copyOf(a).parentId).toBe(placed); expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 }); expect(copyOf(c).parentId).toBe(copyOf(b).id); expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 }); expect(new Set(copies.map((o) => o.id)).size).toBe(4); expect(asset.objects.map((t) => t.id)).not.toContain(copyOf(a).id); stack.undo(); expect(sceneOf().objects).toHaveLength(4); expect(sceneOf().objects.some((o) => o.id === placed)).toBe(false); }); it('survives a save/load round trip', () => { const { project, stack, sceneId, a, b } = setup(); const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); const loaded = roundTrip(project); const asset = findAsset(loaded, create.assetId) as ContainerAsset; expect(asset.type).toBe('container'); expect(asset.objects).toHaveLength(3); const scene = findScene(loaded, sceneId)!; expect(scene.objects.find((o) => o.id === a)?.templateId).toBe(asset.objects[0]!.id); expect(scene.objects.find((o) => o.id === create.objectId)?.assetId).toBe(create.assetId); }); it('deleting an asset removes its template objects from containers too', () => { const { project, stack, sceneId, spriteId, a, b, sceneOf } = setup(); const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); const asset = () => findAsset(project, create.assetId) as ContainerAsset; stack.execute(new DeleteAssetCommand(spriteId)); expect(asset().objects).toHaveLength(0); expect(sceneOf().objects.map((o) => o.id)).toEqual([create.objectId]); stack.undo(); expect(asset().objects).toHaveLength(3); expect(sceneOf().objects).toHaveLength(4); }); it('spawns a container with its content at runtime', () => { const f = setup(); const { stack, sceneId, a, b, sceneOf } = f; const create = new CreateContainerCommand(sceneId, [a, b]); stack.execute(create); const { world } = startWorld(f); const spawned = world.spawn(create.assetId, { 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()).toBe(true); expect(sceneOf().objects).toHaveLength(8); }); }); 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() }); });