Files
feuerwerk/tests/core/hierarchy.test.ts
T
rootandClaude Opus 5 d54a620ebd Add ESLint and Prettier, and close the node test gaps
Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

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

227 lines
10 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,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
import { type Graph, GraphInterpreter, worldPositionOf } from '../../src/core/graph';
import {
ancestorIdsOf,
descendantsOf,
findObject,
localPositionFor,
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('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']);
});
});