Files
feuerwerk/tests/core/graph.test.ts
T
rootandClaude Opus 5 76aa441dce Enabled switch, text assets, mouse input and the jump-and-run example game
Engine additions that the example game needed:
- `enabled` as a shared property of every placeable asset (default on, an
  override on the scene object). A switched-off object is not drawn, does not
  collide and its behavior does not run - and neither does anything parented
  below it. Disabled objects are now hidden in the scene view as well, so the
  editor shows what the game shows; they stay in the hierarchy panel, which is
  where they are selected and switched back on.
- Asset type `text` (text, fontSize, color, align), rendered in scene and game.
- Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`,
  `Is hovered`, hit-tested against colliders, else sprite/text footprints.
- New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text,
  Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game.

Tooling: shared CDP harness (tools/harness.mjs), central project registry with
a guard that keeps destructive scripts off the playground (tools/projects.mjs),
plus smoke suites for the demo scene and the jump-and-run game.

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

429 lines
23 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, type Graph, 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, assetId = `asset-${name}`): EntityHandle {
return { id: name, assetId, name, x, y: 0, z: 0, props: {}, parent: null };
}
const NO_POINTER = { x: 0, y: 0, inside: false, down: false, pressed: false, released: false };
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,
pointer: { ...NO_POINTER },
hitTest: () => false,
isTouching: () => false,
moveAndSlide: (target: EntityHandle, dx: number, dy: number) => {
target.x += dx;
target.y += dy;
return { blockedX: false, blockedY: false };
},
log: (m: string) => logs.push(m),
spawn: () => null,
destroy: () => {},
stop: () => { ctx.stopped = true; },
restart: () => {},
...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({ enabled: true, 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({ enabled: true, 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);
});
});
describe('Spawn node', () => {
const graph: Graph = {
nodes: [
{ id: 'start', type: 'OnStart', x: 0, y: 0, params: {} },
{ id: 'spawn', type: 'Spawn', x: 0, y: 0, params: { asset: 'crate', x: 5, y: 7 } },
{ id: 'log', type: 'Log', x: 0, y: 0, params: {} },
],
edges: [
{ id: 'e1', from: { node: 'start', port: 'exec' }, to: { node: 'spawn', port: 'exec' } },
{ id: 'e2', from: { node: 'spawn', port: 'exec' }, to: { node: 'log', port: 'exec' } },
{ id: 'e3', from: { node: 'spawn', port: 'object' }, to: { node: 'log', port: 'message' } },
],
};
it('asks the context for a new instance at the inline position and outputs it', () => {
const calls: unknown[] = [];
const ctx = context({
spawn: (assetId, position, parent) => {
calls.push({ assetId, position, parent });
return entity('Crate', position.x);
},
});
new GraphInterpreter(graph).fire('OnStart', ctx);
expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]);
expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log
});
it('logs a hint and continues when no asset is selected or the asset is unknown', () => {
const ctx = context({ spawn: () => null });
const noAsset: Graph = { ...graph, nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)) };
new GraphInterpreter(noAsset).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
const ctx2 = context({ spawn: () => null });
new GraphInterpreter(graph).fire('OnStart', ctx2);
expect(ctx2.logs[0]).toBe('Spawn: asset crate not found');
});
});