import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { CommandStack } from '../../src/core/commands/CommandStack'; import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; import { GraphInterpreter, type NodeContext, worldPositionOf } from '../../src/core/graph'; import { descendantsOf, localPositionFor, treeOrder, worldPosition } from '../../src/core/model/hierarchy'; import { createEmptyProject, findScene } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { World } from '../../src/runtime/World'; /** Scene with a sprite asset and three placed boxes: a at (100,100), b at (10,0), c at (0,5). */ function setup() { const project = createEmptyProject('p', 'Hierarchy'); const stack = new CommandStack(project); const scene = new CreateAssetCommand('scene', 'Level'); const sprite = new CreateAssetCommand('sprite', 'Box'); stack.execute(scene); stack.execute(sprite); const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 }); const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 }); const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 }); for (const cmd of [a, b, c]) stack.execute(cmd); const sceneOf = () => findScene(project, scene.asset.id)!; const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!; return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object }; } describe('scene hierarchy', () => { it('re-parenting keeps the world position and moves children along with the parent', () => { const { stack, sceneId, a, b, c, sceneOf, object } = setup(); stack.execute(new SetParentCommand(sceneId, b, a)); // b stays at world (10,0): local becomes (-90,-100,-1). expect(object(b).parentId).toBe(a); expect(object(b).position).toEqual({ x: -90, y: -100, z: -1 }); expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 10, y: 0, z: 0 }); stack.execute(new SetParentCommand(sceneId, c, b)); expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 0, y: 5, z: 0 }); // Moving the root drags the whole subtree. stack.execute(new MoveSceneObjectCommand(sceneId, a, { x: 200, y: 100, z: 1 })); expect(worldPosition(sceneOf(), object(b))).toEqual({ x: 110, y: 0, z: 0 }); expect(worldPosition(sceneOf(), object(c))).toEqual({ x: 100, y: 5, z: 0 }); // Undo restores the previous parent and local position exactly. stack.undo(); stack.undo(); expect(object(c).parentId).toBe(''); expect(object(c).position).toEqual({ x: 0, y: 5, z: 0 }); stack.undo(); expect(object(b).parentId).toBe(''); expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 }); expect(localPositionFor(sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 }); }); it('refuses cycles and self-parenting', () => { const { stack, sceneId, a, b, c } = setup(); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new SetParentCommand(sceneId, c, b)); expect(() => stack.execute(new SetParentCommand(sceneId, a, a))).toThrow(/own parent/); expect(() => stack.execute(new SetParentCommand(sceneId, a, c))).toThrow(/own children/); expect(() => stack.execute(new SetParentCommand(sceneId, a, b))).toThrow(/own children/); }); it('lists objects in tree order and descendants depth-first', () => { const { stack, sceneId, a, b, c, sceneOf } = setup(); // Storage order is a, b, c; parent c under a → c is shown right after a, before b. stack.execute(new SetParentCommand(sceneId, c, a)); expect(treeOrder(sceneOf()).map((e) => [e.object.id, e.depth])).toEqual([[a, 0], [c, 1], [b, 0]]); stack.execute(new SetParentCommand(sceneId, b, c)); expect(descendantsOf(sceneOf(), a).map((o) => o.id)).toEqual([c, b]); }); it('removing an object or deleting an asset takes the subtree along, undo restores it', () => { const { project, stack, sceneId, spriteId, a, b, c, sceneOf } = setup(); const behavior = new CreateAssetCommand('behavior', 'Logic'); stack.execute(behavior); const placeLogic = new PlaceAssetCommand(sceneId, behavior.asset.id, { x: 0, y: 0 }, a); stack.execute(placeLogic); expect(sceneOf().objects.find((o) => o.id === placeLogic.objectId)!.position).toEqual({ x: -100, y: -100, z: -1 }); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new RemoveSceneObjectCommand(sceneId, a)); expect(sceneOf().objects.map((o) => o.id)).toEqual([c]); stack.undo(); expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); expect(sceneOf().objects.find((o) => o.id === b)!.parentId).toBe(a); // Deleting the sprite asset removes a, b, c – and the behavior parented below a. stack.execute(new DeleteAssetCommand(spriteId)); expect(sceneOf().objects).toHaveLength(0); stack.undo(); expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, placeLogic.objectId]); expect(project.assets.map((x) => x.id)).toContain(spriteId); }); it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => { const { project, stack, sceneId, a, b, c } = setup(); stack.execute(new SetParentCommand(sceneId, b, a)); const raw = JSON.parse(JSON.stringify(serializeProject(project))) as { assets: { objects?: Record[] }[] }; const objects = raw.assets[0]!.objects!; // Legacy documents stored the link as `ownerId`. delete objects[1]!.parentId; objects[1]!.ownerId = a; // A cycle a → c → a and a dangling reference are both cleared. objects[0]!.parentId = c; objects[2]!.parentId = a; const loaded = findScene(deserializeProject(raw), sceneId)!; const parentOf = (id: string) => loaded.objects.find((o) => o.id === id)!.parentId; expect(parentOf(b)).toBe(a); expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives objects[1]!.parentId = 'ghost'; delete objects[1]!.ownerId; expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe(''); }); }); describe('runtime hierarchy', () => { it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { const { project, stack, sceneId, a, b, c } = setup(); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new SetParentCommand(sceneId, c, b)); const world = World.instantiate(project, sceneId); const entity = (id: string) => world.findByObjectId(id)!; expect(entity(b).parent).toBe(entity(a)); expect(world.worldPosition(entity(c))).toEqual({ x: 0, y: 5, z: 0 }); entity(a).x += 50; expect(world.worldPosition(entity(c))).toEqual({ x: 50, y: 5, z: 0 }); expect(worldPositionOf(entity(b))).toEqual({ x: 60, y: 0, z: 0 }); world.remove(entity(a)); expect(world.entities).toHaveLength(0); }); it('Get position exposes local and world coordinates, Parent walks up', () => { const parent = { id: 'p', name: 'p', x: 100, y: 20, z: 0, props: {}, parent: null }; const child = { id: 'c', name: 'c', x: 5, y: 5, z: 0, props: {}, parent }; const graph = { nodes: [ { id: 'start', type: 'OnStart', x: 0, y: 0, params: {} }, { id: 'self', type: 'Self', x: 0, y: 0, params: {} }, { id: 'pos', type: 'GetPosition', x: 0, y: 0, params: {} }, { id: 'par', type: 'Parent', x: 0, y: 0, params: {} }, { id: 'ppos', type: 'GetPosition', x: 0, y: 0, params: {} }, { id: 'log1', type: 'Log', x: 0, y: 0, params: {} }, { id: 'log2', type: 'Log', x: 0, y: 0, params: {} }, { id: 'log3', type: 'Log', x: 0, y: 0, params: {} }, ], edges: [ { id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'log1', port: 'exec' } }, { id: 'e2', from: { node: 'log1', port: 'exec' }, to: { node: 'log2', port: 'exec' } }, { id: 'e3', from: { node: 'log2', port: 'exec' }, to: { node: 'log3', port: 'exec' } }, { id: 'e4', from: { node: 'self', port: 'object' }, to: { node: 'pos', port: 'object' } }, { id: 'e5', from: { node: 'self', port: 'object' }, to: { node: 'par', port: 'object' } }, { id: 'e6', from: { node: 'par', port: 'parent' }, to: { node: 'ppos', port: 'object' } }, { id: 'e7', from: { node: 'pos', port: 'worldX' }, to: { node: 'log1', port: 'message' } }, { id: 'e8', from: { node: 'pos', port: 'x' }, to: { node: 'log2', port: 'message' } }, { id: 'e9', from: { node: 'ppos', port: 'worldX' }, to: { node: 'log3', port: 'message' } }, ] }; const logs: string[] = []; const ctx: NodeContext = { self: child, owner: parent, variables: {}, time: 0, dt: 0, findByName: () => null, isKeyDown: () => false, isTouching: () => false, moveAndSlide: () => ({ blockedX: false, blockedY: false }), log: (m) => logs.push(m), spawn: () => null, destroy: () => {}, stop: () => {} }; new GraphInterpreter(graph).fire('OnStart', ctx); expect(logs).toEqual(['105', '5', '100']); }); });