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>
227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
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']);
|
||
});
|
||
});
|