import { describe, expect, it } from 'vitest'; import { DeleteAssetCommand } from '../../src/core/commands/assetCommands'; import { MoveSceneObjectCommand, RemoveSceneObjectsCommand, ReorderObjectCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph'; import { ancestorIdsOf, descendantsOf, findObject, type ObjectTree, treeOrder, hierarchyRows, } from '../../src/core/model/hierarchy'; import { findScene } from '../../src/core/model/Project'; import { localPositionFor } from '../../src/core/properties/transform'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { fakeContext, fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip, startWorld, fire, } from '../helpers'; /** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ function threeBoxes() { 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 }); return { ...f, a, b, c }; } describe('scene hierarchy', () => { it('re-parenting keeps the world position and moves children along with the parent', () => { const { project, stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes(); 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(world(b)).toEqual({ x: 10, y: 0, z: 0 }); stack.execute(new SetParentCommand(sceneId, c, b)); expect(world(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(world(b)).toEqual({ x: 110, y: 0, z: 0 }); expect(world(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(project, 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 } = threeBoxes(); 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 } = threeBoxes(); // 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('hides the subtrees of collapsed objects and marks the rows that can fold', () => { const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); // a > c > b: two levels, so folding a hides both descendants. stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); const rows = (expanded: string[]) => hierarchyRows(sceneOf(), new Set(expanded)).flatMap((e) => e.kind === 'object' ? [[e.object.id, e.depth, e.hasChildren]] : [], ); // Nothing expanded (the default when a scene is opened) – only the top level is listed. expect(rows([])).toEqual([[a, 0, true]]); expect(rows([a])).toEqual([ [a, 0, true], [c, 1, true], ]); expect(rows([a, c])).toEqual([ [a, 0, true], [c, 1, true], [b, 2, false], ]); // Expanding a nested object without its parent shows nothing extra: every ancestor must be open. expect(rows([c])).toEqual([[a, 0, true]]); }); it('lists the ancestors of an object nearest first, so a selection can be unfolded', () => { const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); expect(ancestorIdsOf(sceneOf(), b)).toEqual([c, a]); expect(ancestorIdsOf(sceneOf(), a)).toEqual([]); }); it('removing an object or deleting an asset takes the subtree along, undo restores it', () => { const f = threeBoxes(); const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object } = f; const logic = place(f, makeBehavior(f, 'Logic').id, { x: 0, y: 0 }, a); expect(object(logic).position).toEqual({ x: -100, y: -100, z: -1 }); stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new RemoveSceneObjectsCommand(sceneId, [a])); expect(sceneOf().objects.map((o) => o.id)).toEqual([c]); stack.undo(); expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, logic]); expect(object(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, logic]); expect(project.assets.map((x) => x.id)).toContain(spriteId); }); it('a behavior is parented like any object: validated, kept across a round trip, dropped when dangling', () => { const f = makeSpriteScene(); const { project, stack, sceneId, object } = f; const box = place(f, f.spriteId, { x: 0, y: 0 }); const logic = place(f, makeBehavior(f).id, { x: 0, y: 0 }); expect(() => stack.execute(new SetParentCommand(sceneId, logic, logic))).toThrow(/own parent/); stack.execute(new SetParentCommand(sceneId, logic, box)); expect(object(logic).parentId).toBe(box); expect(findObject(findScene(roundTrip(project), sceneId)!, logic)!.parentId).toBe(box); // A dangling parent is dropped by the loader. const raw = serializeProject(project) as { assets: { objects?: { id: string; parentId: string }[] }[] }; raw.assets[0]!.objects!.find((o) => o.id === logic)!.parentId = 'ghost'; expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe(''); }); it('the loader drops dangling parents and breaks cycles', () => { const { project, stack, sceneId, a, b, c } = threeBoxes(); stack.execute(new SetParentCommand(sceneId, b, a)); const raw = serializeProject(project) as { assets: { objects?: Record[] }[] }; const objects = raw.assets[0]!.objects!; // 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) => findObject(loaded, 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'; expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe(''); }); }); describe('ReorderObjectCommand', () => { /** Ids in tree order – the order the panel lists and the tie-break for equal `z`. */ const order = (scene: ObjectTree) => treeOrder(scene).map((e) => e.object.id); it('moves an object in front of a sibling and undo puts it back', () => { const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); expect(order(sceneOf())).toEqual([a, b, c]); stack.execute(new ReorderObjectCommand(sceneId, c, a)); expect(order(sceneOf())).toEqual([c, a, b]); stack.undo(); expect(order(sceneOf())).toEqual([a, b, c]); }); it('moves it behind the last sibling when no successor is named', () => { const { stack, sceneId, a, b, c, sceneOf } = threeBoxes(); stack.execute(new ReorderObjectCommand(sceneId, a, '')); expect(order(sceneOf())).toEqual([b, c, a]); }); it('orders children among themselves, leaving the subtree where it is', () => { const f = threeBoxes(); const { stack, sceneId, a, b, c } = f; const x = place(f, f.spriteId, { x: 0, y: 0 }, a); const y = place(f, f.spriteId, { x: 0, y: 0 }, a); expect(order(f.sceneOf())).toEqual([a, x, y, b, c]); stack.execute(new ReorderObjectCommand(sceneId, y, x)); expect(order(f.sceneOf())).toEqual([a, y, x, b, c]); // The move is among siblings only: the parent keeps its own place in the list. expect(f.object(y).parentId).toBe(a); }); it('refuses a target that is not a sibling, so a reorder never re-parents by accident', () => { const f = threeBoxes(); const child = place(f, f.spriteId, { x: 0, y: 0 }, f.a); expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, child))).toThrow( /among its own siblings/, ); expect(() => f.stack.execute(new ReorderObjectCommand(f.sceneId, f.b, f.b))).toThrow( /in front of itself/, ); expect(order(f.sceneOf())).toEqual([f.a, child, f.b, f.c]); }); }); describe('runtime hierarchy', () => { it('entities mirror parents: world positions follow the parent, Destroy takes children along', () => { const f = threeBoxes(); const { stack, sceneId, a, b, c } = f; stack.execute(new SetParentCommand(sceneId, b, a)); stack.execute(new SetParentCommand(sceneId, c, b)); const { world } = startWorld(f); const entity = (id: string) => world.findByObjectId(id)!; expect(entity(b).parent).toBe(entity(a)); expect(worldPositionOf(entity(c))).toEqual({ x: 0, y: 5, z: 0 }); entity(a).x += 50; expect(worldPositionOf(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 = { ...fakeEntity('p', 100), y: 20 }; const child = { ...fakeEntity('c', 5), y: 5, parent }; // On start → Log(world x of self) → Log(local x of self) → Log(world x of self's parent) const graph: 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 ctx = fakeContext({ self: child, owner: parent, variables: {} }); fire(new GraphInterpreter(graph), 'OnStart', ctx); expect(ctx.logs).toEqual(['105', '5', '100']); }); });