Files
feuerwerk/tests/core/hierarchy.test.ts
T
rootandClaude Opus 5 4568c6581b Add pixel-size fit and drag-to-reorder in the hierarchy
The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.

Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:20:46 +02:00

273 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { DeleteAssetCommand } from '../../src/core/commands/assetCommands';
import {
MoveSceneObjectCommand,
RemoveSceneObjectCommand,
ReorderObjectCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
import {
ancestorIdsOf,
descendantsOf,
findObject,
localPositionFor,
type ObjectTree,
treeOrder,
visibleTreeOrder,
} from '../../src/core/model/hierarchy';
import { findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import {
fakeContext,
fakeEntity,
makeBehavior,
makeSpriteScene,
place,
roundTrip,
startWorld,
} 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 { 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(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[]) =>
visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [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 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, 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 accepts legacy ownerId, 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<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) => 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';
delete objects[1]!.ownerId;
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: {} });
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['105', '5', '100']);
});
});