Let a behavior be a node in another graph

A behavior that has a Start and an End node can now be dropped into any
other graph as a node of its own. What is declared on the start node
becomes the inputs of that node, what hangs on the end node its outputs;
the port list is derived from the sub graph, not copied, so declaring a
port in one graph grows the node in the other without touching it. The
node carries the behavior's name and the palette lists behaviors by
name, so a call is one grab instead of a node plus a dropdown.

An embedded behavior has no object and therefore no place for
variables: every call starts on the asset defaults and communicates
only through its ports. That is the conservative direction - state can
still be added later, and shared state already has a home in a behavior
object in the scene. Node ids are unique per graph only, so each call
gets its own pulse cache; without that, two graphs would collide.

Renaming a port used to silently drop the value typed on the calling
node. It is carried along now, project wide, like the edges.
This commit is contained in:
root committed 2026-09-09 20:01:49 +02:00
1 parent 64e034ffb9
commit 6702ff8abe
31 files changed
+1402 -107

No files matched your search

+241
View File
@@ -0,0 +1,241 @@
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
type Graph,
GraphInterpreter,
RUN_BEHAVIOR,
resolveDef,
terminalNode,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
/**
* A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its
* start and end node. Small on purpose – what the tests are about is the call, not the arithmetic.
*/
function doubler(fixture: ProjectFixture, name = 'Double'): BehaviorAsset {
const asset = makeBehavior(fixture, name);
const edit = graphEditor(fixture, asset);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_START, { key: 'in1', kind: 'number' }));
fixture.stack.execute(new AddPortCommand(asset.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
const times = edit.addNode('Multiply', { b: 2 });
edit.wire([
[start, 'exec', end, 'exec'],
[start, 'in1', times, 'a'],
[times, 'result', end, 'out1'],
]);
return asset;
}
/** A caller that runs `callee` on start with `input` and stores the result in its own `result` var. */
function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number): BehaviorAsset {
const asset = makeBehavior(fixture, 'Caller', ['result']);
const edit = graphEditor(fixture, asset);
const start = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input });
const set = edit.addNode('SetVariable', { variable: 'result' });
edit.wire([
[start, 'exec', call, 'exec'],
[call, 'exec', set, 'exec'],
[call, 'out1', set, 'value'],
]);
return asset;
}
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
return { ctx, interpreter };
}
describe('a behavior used as a node', () => {
it('runs the other graph in place and hands its result back', () => {
const fixture = makeProject();
const caller = callerOf(fixture, doubler(fixture), 21);
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(42);
});
it('takes its ports from the other behavior, not from its own type', () => {
const fixture = makeProject();
const callee = doubler(fixture);
const caller = callerOf(fixture, callee, 1);
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
const def = resolveDef(call, behaviorLookup(fixture.project))!;
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
expect(def.label).toBe('Double');
});
it('keeps its variables to itself: the caller’s are untouched and nothing survives the call', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'Counter', ['count']);
const edit = graphEditor(fixture, callee);
const start = edit.addNode(BEHAVIOR_START);
const end = edit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
const get = edit.addNode('GetVariable', { variable: 'count' });
const plus = edit.addNode('Add', { b: 1 });
const set = edit.addNode('SetVariable', { variable: 'count' });
edit.wire([
[start, 'exec', set, 'exec'],
[get, 'value', plus, 'a'],
[plus, 'result', set, 'value'],
[set, 'exec', end, 'exec'],
[plus, 'result', end, 'out1'],
]);
const caller = makeBehavior(fixture, 'Caller', ['result', 'count']);
const cEdit = graphEditor(fixture, caller);
const onStart = cEdit.addNode('OnStart');
const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const store = cEdit.addNode('SetVariable', { variable: 'result' });
cEdit.wire([
[onStart, 'exec', first, 'exec'],
[first, 'exec', second, 'exec'],
[second, 'exec', store, 'exec'],
[second, 'out1', store, 'value'],
]);
const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 });
// 1, not 2: the second call starts from the asset default again, not from what the first left.
expect(ctx.variables.result).toBe(1);
// The caller has a `count` of its own – the nested run must not have written into it.
expect(ctx.variables.count).toBe(500);
});
it('runs the nested graph in its own frame, so equal node ids cannot collide', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'Ninetynine');
const cEdit = graphEditor(fixture, callee);
const start = cEdit.addNode(BEHAVIOR_START);
const end = cEdit.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, BEHAVIOR_END, { key: 'out1', kind: 'number' }));
const theirs = cEdit.addNode('Number', { value: 99 });
cEdit.wire([
[start, 'exec', end, 'exec'],
[theirs, 'value', end, 'out1'],
]);
const caller = makeBehavior(fixture, 'Caller', ['result']);
const edit = graphEditor(fixture, caller);
const onStart = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const set = edit.addNode('SetVariable', { variable: 'result' });
const mine = edit.addNode('Number', { value: 7 });
edit.wire([
[onStart, 'exec', call, 'exec'],
// Read *after* the call, and from the caller's own node – the point of the test.
[call, 'exec', set, 'exec'],
[mine, 'value', set, 'value'],
]);
// Ids are unique in practice; forcing a collision is the only way to show the frames are real.
// Both nodes have a `value` output, so a shared cache would hand the caller the nested 99.
const shared = 'same-id';
retarget(callee.graph, theirs.id, shared);
retarget(caller.graph, mine.id, shared);
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7);
});
it('refuses to run itself rather than looping forever', () => {
const fixture = makeProject();
const asset = doubler(fixture, 'Loop');
const edit = graphEditor(fixture, asset);
const start = terminalNode(asset.graph, BEHAVIOR_START)!;
const self = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id });
edit.connect(start, 'exec', self, 'exec');
const caller = callerOf(fixture, asset, 1);
expect(() => runOnStart(fixture, caller, { result: 0 })).toThrow(/runs itself/);
});
it('a call into nothing is quiet: no behavior chosen, or one without a start node', () => {
const fixture = makeProject();
const empty = makeBehavior(fixture, 'Empty');
for (const target of ['', empty.id]) {
const caller = makeBehavior(fixture, `C${target}`, ['result']);
const edit = graphEditor(fixture, caller);
const start = edit.addNode('OnStart');
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target });
const set = edit.addNode('SetVariable', { variable: 'result' });
edit.wire([
[start, 'exec', call, 'exec'],
// The flow carries on past the call, which is what keeps a dangling reference harmless.
[call, 'exec', set, 'exec'],
]);
expect(runOnStart(fixture, caller, { result: 7 }).ctx.variables.result).toBe(0);
}
});
});
describe('declared ports', () => {
it('renaming one rewrites the edges in this graph and in every calling one', () => {
const fixture = makeProject();
const callee = doubler(fixture);
const caller = callerOf(fixture, callee, 3);
fixture.stack.execute(new RenamePortCommand(callee.id, BEHAVIOR_START, 'in1', 'factor'));
const start = terminalNode(callee.graph, BEHAVIOR_START)!;
expect(start.ports).toEqual([{ key: 'factor', kind: 'number' }]);
expect(callee.graph.edges.some((e) => e.from.port === 'factor')).toBe(true);
// The value typed into the calling node moves with the port; losing it would be silent.
expect(caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params).toMatchObject({ factor: 3 });
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6);
});
it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => {
const fixture = makeProject();
const callee = doubler(fixture);
const caller = callerOf(fixture, callee, 4);
const edgesBefore = { callee: callee.graph.edges.length, caller: caller.graph.edges.length };
fixture.stack.execute(new RemovePortCommand(callee.id, BEHAVIOR_END, 'out1'));
expect(callee.graph.edges.length).toBe(edgesBefore.callee - 1);
expect(caller.graph.edges.length).toBe(edgesBefore.caller - 1);
fixture.stack.undo();
expect(callee.graph.edges.length).toBe(edgesBefore.callee);
expect(caller.graph.edges.length).toBe(edgesBefore.caller);
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(8);
});
it('survives saving and loading', () => {
const fixture = makeProject();
const callee = doubler(fixture);
callerOf(fixture, callee, 6);
const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(fixture.project))));
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
const ctx = fakeContext({ variables: { result: 0 } });
interpreter.fire('OnStart', ctx);
expect(ctx.variables.result).toBe(12);
});
it('a behavior has one start and one end node, never two', () => {
const fixture = makeProject();
const asset = doubler(fixture);
for (const type of [BEHAVIOR_START, BEHAVIOR_END]) {
expect(() => fixture.stack.execute(new AddNodeCommand(asset.id, type, 0, 0))).toThrow(CommandError);
}
});
});
/** Gives a node a different id, edges included – used to force an id collision across graphs. */
function retarget(graph: Graph, from: string, to: string): void {
graph.nodes.find((n) => n.id === from)!.id = to;
for (const edge of graph.edges) {
if (edge.from.node === from) edge.from.node = to;
if (edge.to.node === from) edge.to.node = to;
}
}