Files
feuerwerk/tests/core/graph.test.ts
T
rootandClaude Fable 5.1 0a59513b74 Collider asset type, CollisionSystem and collision nodes
- Asset type 'collider' (box/circle) placed as child of a body object
- 'Add collider' shortcut (hierarchy context menu + inspector)
- CollisionSystem with enter/stay/exit pairs, sibling colliders excluded
- Nodes: On collision enter/stay/exit, Is touching
- Green outline in edit mode, hidden in play

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:25:04 +02:00

380 lines
21 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 { buildAsset } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { createEmptyProject } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
function entity(name: string, x = 0): EntityHandle {
return { id: name, name, x, y: 0, z: 0, props: {}, parent: null };
}
function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: string[]; stopped: boolean } {
const logs: string[] = [];
const ctx = {
self: entity('self'),
owner: entity('owner'),
variables: { speed: 100 },
time: 0,
dt: 0.5,
logs,
stopped: false,
findByName: () => null,
isKeyDown: () => false,
isTouching: () => false,
log: (m: string) => logs.push(m),
destroy: () => {},
stop: () => { ctx.stopped = true; },
...overrides,
};
return ctx;
}
/** Builds "On key held → Move(owner, dx = speed × dt)" through commands, like the editor would. */
function buildMoverProject() {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
const held = new AddNodeCommand('mover', 'OnKeyHeld', 0, 0, { key: 'ArrowRight' });
const owner = new AddNodeCommand('mover', 'Owner', 0, 100);
const speed = new AddNodeCommand('mover', 'GetVariable', 0, 200, { variable: 'speed' });
const mul = new AddNodeCommand('mover', 'Multiply', 200, 150);
const move = new AddNodeCommand('mover', 'Move', 400, 0);
for (const c of [held, owner, speed, mul, move]) stack.execute(c);
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('mover', { node: owner.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
stack.execute(new ConnectCommand('mover', { node: speed.node.id, port: 'value' }, { node: mul.node.id, port: 'a' }));
stack.execute(new ConnectCommand('mover', { node: held.node.id, port: 'dt' }, { node: mul.node.id, port: 'b' }));
stack.execute(new ConnectCommand('mover', { node: mul.node.id, port: 'result' }, { node: move.node.id, port: 'dx' }));
return { project, behavior, stack, nodes: { held, owner, speed, mul, move } };
}
describe('node registry', () => {
it('registers the built-in node set with well-formed ports', () => {
const defs = allNodeDefs();
expect(defs.length).toBeGreaterThan(20);
for (const def of defs) {
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
else expect(def.run).toBeTypeOf('function');
expect(keys.every((k) => k.length > 0)).toBe(true);
}
});
it('type-checks port connections', () => {
expect(portsCompatible('exec', 'exec')).toBe(true);
expect(portsCompatible('exec', 'number')).toBe(false);
expect(portsCompatible('number', 'any')).toBe(true);
expect(portsCompatible('any', 'boolean')).toBe(true);
expect(portsCompatible('number', 'boolean')).toBe(false);
expect(portsCompatible('object', 'object')).toBe(true);
});
});
describe('GraphInterpreter', () => {
it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => {
const { behavior } = buildMoverProject();
const interpreter = new GraphInterpreter(behavior.graph);
const ctx = context({ variables: { speed: 100 } });
const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
expect(fired).toBe(1);
expect(ctx.owner!.x).toBe(50);
expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0);
expect(ctx.owner!.x).toBe(50);
});
it('uses inline values for unconnected inputs', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const self = new AddNodeCommand('b', 'Self', 0, 0);
const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 7, dy: -3 });
for (const c of [start, self, move]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
const ctx = context();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(7);
expect(ctx.self.y).toBe(-3);
});
it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const self = new AddNodeCommand('b', 'Self', 0, 0);
const clamp = new AddNodeCommand('b', 'Clamp', 0, 0, { value: 5 });
const move = new AddNodeCommand('b', 'Move', 0, 0, { dx: 4 });
for (const c of [start, self, clamp, move]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: move.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: self.node.id, port: 'object' }, { node: move.node.id, port: 'object' }));
stack.execute(new ConnectCommand('b', { node: clamp.node.id, port: 'result' }, { node: move.node.id, port: 'dy' }));
const ctx = context();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
expect(ctx.self.y).toBe(1); // clamp(5, 0, 1)
});
it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'Mover', 'mover') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('mover', 'speed', 'number'));
const update = new AddNodeCommand('mover', 'OnUpdate', 0, 0);
const owner = new AddNodeCommand('mover', 'Owner', 0, 0);
const axis = new AddNodeCommand('mover', 'AxisInput', 0, 0);
const speed = new AddNodeCommand('mover', 'GetVariable', 0, 0, { variable: 'speed' });
const move = new AddNodeCommand('mover', 'Move', 0, 0);
for (const c of [update, owner, axis, speed, move]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('mover', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(update, 'exec', move, 'exec');
connect(update, 'dt', move, 'dt');
connect(owner, 'object', move, 'object');
connect(axis, 'x', move, 'dx');
connect(axis, 'y', move, 'dy');
connect(speed, 'value', move, 'scale');
expect(behavior.graph.nodes).toHaveLength(5);
const interpreter = new GraphInterpreter(behavior.graph);
const run = (keys: string[]) => {
const ctx = context({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) });
interpreter.fire('OnUpdate', ctx, { dt: 0.5 });
return { x: ctx.owner!.x, y: ctx.owner!.y };
};
expect(run([])).toEqual({ x: 0, y: 0 });
expect(run(['ArrowRight'])).toEqual({ x: 50, y: 0 });
expect(run(['ArrowLeft'])).toEqual({ x: -50, y: 0 });
expect(run(['ArrowUp'])).toEqual({ x: 0, y: -50 });
const diagonal = run(['ArrowLeft', 'ArrowUp']);
expect(Math.hypot(diagonal.x, diagonal.y)).toBeCloseTo(50, 6); // same distance as a straight move
expect(diagonal.x).toBeCloseTo(-50 / Math.SQRT2, 6);
expect(diagonal.y).toBeCloseTo(-50 / Math.SQRT2, 6);
expect(run(['ArrowLeft', 'ArrowRight'])).toEqual({ x: 0, y: 0 }); // opposite keys cancel
});
it('vector and math helpers', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const norm = new AddNodeCommand('b', 'Normalize', 0, 0, { x: 3, y: 4 });
const len = new AddNodeCommand('b', 'Length', 0, 0, { x: 3, y: 4 });
const sqrt = new AddNodeCommand('b', 'Sqrt', 0, 0, { value: 16 });
const abs = new AddNodeCommand('b', 'Abs', 0, 0, { value: -2 });
const min = new AddNodeCommand('b', 'Min', 0, 0, { a: 3, b: -1 });
const max = new AddNodeCommand('b', 'Max', 0, 0, { a: 3, b: -1 });
const logs = [norm, len, sqrt, abs, min, max].map(() => new AddNodeCommand('b', 'Log', 0, 0));
for (const c of [start, norm, len, sqrt, abs, min, max, ...logs]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
let previous = start;
for (const log of logs) {
connect(previous, 'exec', log, 'exec');
previous = log;
}
connect(norm, 'x', logs[0]!, 'message');
connect(len, 'result', logs[1]!, 'message');
connect(sqrt, 'result', logs[2]!, 'message');
connect(abs, 'result', logs[3]!, 'message');
connect(min, 'result', logs[4]!, 'message');
connect(max, 'result', logs[5]!, 'message');
const ctx = context();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']);
});
it('follows Branch, writes variables and logs', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
stack.execute(new AddVariableCommand('b', 'count', 'number'));
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const get = new AddNodeCommand('b', 'GetVariable', 0, 0, { variable: 'count' });
const cmp = new AddNodeCommand('b', 'Compare', 0, 0, { op: '<', b: 3 });
const branch = new AddNodeCommand('b', 'Branch', 0, 0);
const add = new AddNodeCommand('b', 'Add', 0, 0, { b: 1 });
const set = new AddNodeCommand('b', 'SetVariable', 0, 0, { variable: 'count' });
const logThen = new AddNodeCommand('b', 'Log', 0, 0, { message: 'then' });
const logElse = new AddNodeCommand('b', 'Log', 0, 0, { message: 'else' });
for (const c of [start, get, cmp, branch, add, set, logThen, logElse]) stack.execute(c);
const connect = (from: AddNodeCommand, fp: string, to: AddNodeCommand, tp: string) =>
stack.execute(new ConnectCommand('b', { node: from.node.id, port: fp }, { node: to.node.id, port: tp }));
connect(start, 'exec', branch, 'exec');
connect(get, 'value', cmp, 'a');
connect(cmp, 'result', branch, 'condition');
connect(branch, 'then', set, 'exec');
connect(get, 'value', add, 'a');
connect(add, 'result', set, 'value');
connect(set, 'exec', logThen, 'exec');
connect(branch, 'else', logElse, 'exec');
const interpreter = new GraphInterpreter(behavior.graph);
const ctx = context({ variables: { count: 0 } });
for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx);
expect(ctx.variables.count).toBe(3);
expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
});
it('reports traversed edges and nodes to the trace listener', () => {
const { behavior, nodes } = buildMoverProject();
const interpreter = new GraphInterpreter(behavior.graph);
const edges: string[] = [];
const visited: string[] = [];
interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
interpreter.fire('OnKeyHeld', context(), { dt: 1 });
expect(edges).toHaveLength(1);
expect(visited).toContain(nodes.mul.node.id);
expect(visited).toContain(nodes.move.node.id);
});
it('stops a runaway flow instead of hanging', () => {
const project = createEmptyProject('p', 'P');
const behavior = buildAsset('behavior', 'B', 'b') as BehaviorAsset;
project.assets.push(behavior);
const stack = new CommandStack(project);
const start = new AddNodeCommand('b', 'OnStart', 0, 0);
const a = new AddNodeCommand('b', 'Sequence', 0, 0);
const b = new AddNodeCommand('b', 'Sequence', 0, 0);
for (const c of [start, a, b]) stack.execute(c);
stack.execute(new ConnectCommand('b', { node: start.node.id, port: 'exec' }, { node: a.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: a.node.id, port: 'a' }, { node: b.node.id, port: 'exec' }));
stack.execute(new ConnectCommand('b', { node: b.node.id, port: 'a' }, { node: a.node.id, port: 'exec' }));
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', context())).toThrow(/step limit/);
});
});
describe('graph commands', () => {
it('rejects incompatible, self and duplicate connections', () => {
const { stack, nodes } = buildMoverProject();
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/Cannot connect/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.mul.node.id, port: 'result' }, { node: nodes.mul.node.id, port: 'a' }))).toThrow(/itself/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'exec' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Already/);
expect(() => stack.execute(new ConnectCommand('mover', { node: nodes.held.node.id, port: 'nope' }, { node: nodes.move.node.id, port: 'exec' }))).toThrow(/Port/);
});
it('replaces the edge of a data input and restores it on undo', () => {
const { behavior, stack, nodes } = buildMoverProject();
const literal = new AddNodeCommand('mover', 'Number', 0, 0, { value: 9 });
stack.execute(literal);
const before = behavior.graph.edges.length;
stack.execute(new ConnectCommand('mover', { node: literal.node.id, port: 'value' }, { node: nodes.move.node.id, port: 'dx' }));
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(literal.node.id);
stack.undo();
expect(behavior.graph.edges.find((e) => e.to.node === nodes.move.node.id && e.to.port === 'dx')!.from.node).toBe(nodes.mul.node.id);
});
it('removing a node drops its edges; undo restores both', () => {
const { behavior, stack, nodes } = buildMoverProject();
const edgesBefore = behavior.graph.edges.length;
stack.execute(new RemoveNodeCommand('mover', nodes.mul.node.id));
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(false);
expect(behavior.graph.edges.length).toBe(edgesBefore - 3);
stack.undo();
expect(behavior.graph.nodes.some((n) => n.id === nodes.mul.node.id)).toBe(true);
expect(behavior.graph.edges.length).toBe(edgesBefore);
stack.execute(new DisconnectCommand('mover', behavior.graph.edges[0]!.id));
expect(behavior.graph.edges.length).toBe(edgesBefore - 1);
stack.undo();
expect(behavior.graph.edges.length).toBe(edgesBefore);
});
it('coerces declared params and keeps inline input values', () => {
const { behavior, stack, nodes } = buildMoverProject();
stack.execute(new SetNodeParamCommand('mover', nodes.held.node.id, 'key', 'ArrowLeft'));
stack.execute(new SetNodeParamCommand('mover', nodes.move.node.id, 'dy', 4));
const held = behavior.graph.nodes.find((n) => n.id === nodes.held.node.id)!;
const move = behavior.graph.nodes.find((n) => n.id === nodes.move.node.id)!;
expect(held.params.key).toBe('ArrowLeft');
expect(move.params.dy).toBe(4);
stack.undo();
expect(move.params.dy).toBeUndefined();
});
});
describe('variables', () => {
it('adds, renames (including graph references and overrides) and removes variables', () => {
const { project, behavior, stack, nodes } = buildMoverProject();
const scene = buildAsset('scene', 'S', 's');
project.assets.push(scene);
const place = new PlaceAssetCommand('s', 'mover', { x: 0, y: 0 });
stack.execute(place);
const object = (scene as { objects: { id: string; overrides: Record<string, unknown> }[] }).objects[0]!;
object.overrides.speed = 5;
stack.execute(new RenameVariableCommand('mover', 'speed', 'velocity'));
expect(behavior.variables[0]!.key).toBe('velocity');
expect(behavior.props).toEqual({ velocity: 0 });
expect(object.overrides).toEqual({ velocity: 5 });
expect(behavior.graph.nodes.find((n) => n.id === nodes.speed.node.id)!.params.variable).toBe('velocity');
stack.undo();
expect(behavior.variables[0]!.key).toBe('speed');
expect(object.overrides).toEqual({ speed: 5 });
expect(() => stack.execute(new AddVariableCommand('mover', 'speed', 'number'))).toThrow(/already/);
expect(() => stack.execute(new AddVariableCommand('mover', '1bad', 'number'))).toThrow(/names/);
stack.execute(new RemoveVariableCommand('mover', 'speed'));
expect(behavior.variables).toHaveLength(0);
expect(object.overrides).toEqual({});
stack.undo();
expect(behavior.variables).toHaveLength(1);
expect(object.overrides).toEqual({ speed: 5 });
});
it('the owner is the parent: validated, kept across a round trip, dropped when dangling', () => {
const project = createEmptyProject('p', 'P');
const scene = buildAsset('scene', 'S', 's');
const sprite = buildAsset('sprite', 'Box', 'box');
const behavior = buildAsset('behavior', 'B', 'b');
project.assets.push(scene, sprite, behavior);
const stack = new CommandStack(project);
const placeBox = new PlaceAssetCommand('s', 'box', { x: 0, y: 0 });
const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
stack.execute(placeBox);
stack.execute(placeB);
expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/);
stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId));
const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects;
expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId);
// Round trip keeps the parent; a dangling parent is dropped by the loader.
expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId);
const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] };
raw.assets[0]!.objects![1]!.parentId = 'ghost';
expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe('');
});
});
describe('behavior serialization', () => {
it('round-trips variables and graph, dropping unknown node types and dangling edges', () => {
const { project, behavior } = buildMoverProject();
const loaded = deserializeProject(serializeProject(project)).assets.find((a) => a.id === 'mover') as BehaviorAsset;
expect(loaded.variables).toEqual(behavior.variables);
expect(loaded.graph).toEqual(behavior.graph);
expect(loaded.props).toEqual({ speed: 0 });
const raw = serializeProject(project) as { assets: { graph?: { nodes: unknown[]; edges: unknown[] } }[] };
raw.assets[0]!.graph!.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
raw.assets[0]!.graph!.edges.push({ id: 'e-bad', from: { node: 'zzz', port: 'exec' }, to: { node: behavior.graph.nodes[0]!.id, port: 'exec' } });
const cleaned = deserializeProject(raw).assets[0] as BehaviorAsset;
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
});
});