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.
242 lines
11 KiB
TypeScript
242 lines
11 KiB
TypeScript
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;
|
||
}
|
||
}
|