Files
feuerwerk/tests/core/hierarchy.test.ts
T
rootandClaude Opus 5 495756b500 Remove exports and methods nothing uses any more
A reference search over every export and method in src/ found code that
only tests still called, or that was never called at all:

- RemoveSceneObjectCommand: the product only removes through the
  multi-object command; the tests now use that one too.
- NodeRegistry.requireNodeDef: a second function of the same name in
  commands/require.ts raises a CommandError instead of throwing, and is
  the one the product uses. The twin invited the wrong import.
- isTextureAsset, listContainers, AssetTabEditor.show,
  FocusManager.destroy, KeyboardBridge.selectAll and the getters
  ColorField.value, ListView.selected, NumberField.number.

Also: Theme.controlActive now matches the resting control colour, and a
new note asks what editing the graph during play would take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:15:49 +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,
RemoveSceneObjectsCommand,
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 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 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']);
});
});