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:
1 parent
64e034ffb9
commit
6702ff8abe
31 files changed
+1402
-107
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user