164 lines
9.2 KiB
TypeScript
164 lines
9.2 KiB
TypeScript
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<string, unknown>[] }[] };
|
||
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']);
|
||
});
|
||
});
|