Files
feuerwerk/tests/core/graph.test.ts
T

290 lines
16 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, SetOwnerCommand } 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: {} };
}
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,
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('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('owner references are validated and cleared when the owner disappears', () => {
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 SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/);
stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId));
const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects;
expect(objects()[1]!.ownerId).toBe(placeBox.objectId);
// Round trip keeps the owner; a dangling owner is dropped by the loader.
const loaded = deserializeProject(serializeProject(project));
expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId);
const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] };
raw.assets[0]!.objects![1]!.ownerId = 'ghost';
expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).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);
});
});