A behavior built as a plain behavior, with On start and no Behavior start, did nothing when another behavior called it through Run behavior. The node only showed a warning. Such a behavior should run as a call too, without the author having to rebuild its entry. - New callEntries() in nested.ts returns the Behavior start node or, when there is none, every On start node. callProblem uses it too. - GraphInterpreter.call() follows all entries one after the other, in graph order, in the same frame and with the same variables. - Behavior end still returns what reaches it. A call through On start has no inputs, because only the start node declares them. - It is a fallback only. With a Behavior start present, On start nodes stay out of the call. - The node warning now reads "No start or On start node - does nothing" and only shows when neither exists. - Keep and local state machines behave as before. Tests: - ./check.sh: 163 files, 2396 tests, CHECK EXIT 0 (before 2392). - 4 new unit tests in tests/core/nested-behavior.test.ts: the flow after On start runs, the return value arrives, several On start nodes run in graph order, a start node keeps On start out. - e2e 19: ALL PASSED, 35 checks. Only the expected warning text changed, the check sum stays at 1385. - Counter-checks: without the fallback 3 tests fail. With only the first On start exactly the order test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
18 KiB
TypeScript
391 lines
18 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CommandError } from '../../src/core/commands/Command';
|
||
import { AddNodeCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands';
|
||
import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
BEHAVIOR_END,
|
||
BEHAVIOR_PARAM,
|
||
BEHAVIOR_START,
|
||
callProblem,
|
||
type Graph,
|
||
GraphInterpreter,
|
||
portSignature,
|
||
RUN_BEHAVIOR,
|
||
resolveDef,
|
||
terminalNode,
|
||
} from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||
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, start.id, { key: 'in1', kind: 'number' }));
|
||
fixture.stack.execute(new AddPortCommand(asset.id, end.id, { 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.variables(['result'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', call, 'exec'],
|
||
[call, 'exec', set, 'exec'],
|
||
[call, 'out1', set, 'result'],
|
||
]);
|
||
return asset;
|
||
}
|
||
|
||
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
|
||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(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, caller.graph, graphLookup(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, end.id, { key: 'out1', kind: 'number' }));
|
||
const get = edit.variables(['count']);
|
||
const plus = edit.addNode('Add', { b: 1 });
|
||
const set = edit.variables(['count'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[get, 'count', plus, 'a'],
|
||
[plus, 'result', set, 'count'],
|
||
[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.variables(['result'], { write: true });
|
||
cEdit.wire([
|
||
[onStart, 'exec', first, 'exec'],
|
||
[first, 'exec', second, 'exec'],
|
||
[second, 'exec', store, 'exec'],
|
||
[second, 'out1', store, 'result'],
|
||
]);
|
||
|
||
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, end.id, { 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.variables(['result'], { write: true });
|
||
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, 'result'],
|
||
]);
|
||
|
||
// 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.variables(['result'], { write: true });
|
||
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('a call that cannot do anything says so', () => {
|
||
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
|
||
const fixture = makeProject();
|
||
const lookup = () => graphLookup(fixture.project);
|
||
const callee = doubler(fixture);
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const edit = graphEditor(fixture, caller);
|
||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: '' });
|
||
const problem = () => callProblem(call, lookup(), caller.id);
|
||
|
||
expect(problem()).toBe('No behavior picked');
|
||
call.params[BEHAVIOR_PARAM] = caller.id;
|
||
expect(problem()).toBe('A behavior cannot run itself');
|
||
call.params[BEHAVIOR_PARAM] = 'gone';
|
||
expect(problem()).toBe('That behavior is gone');
|
||
call.params[BEHAVIOR_PARAM] = makeBehavior(fixture, 'Empty').id;
|
||
expect(problem()).toBe('No start or On start node – does nothing');
|
||
call.params[BEHAVIOR_PARAM] = callee.id;
|
||
expect(problem()).toBe(null);
|
||
// Only a call can be dead this way; no other node type may be marked.
|
||
expect(callProblem(edit.addNode('OnStart'), lookup(), caller.id)).toBe(null);
|
||
});
|
||
|
||
it('shows up in what the node view redraws on, or it would be drawn once and never again', () => {
|
||
const fixture = makeProject();
|
||
// A callee that declares no ports at all: losing its start node changes nothing about the
|
||
// calling node except that it is now dead. Anything with ports would move the signature by
|
||
// itself and prove nothing.
|
||
const callee = makeBehavior(fixture, 'Bare');
|
||
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||
const signature = () => portSignature(call, caller.graph, graphLookup(fixture.project), caller.id);
|
||
|
||
const healthy = signature();
|
||
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
|
||
expect(resolveDef(call, caller.graph, graphLookup(fixture.project))!.label).toBe('Bare');
|
||
expect(signature()).not.toBe(healthy);
|
||
});
|
||
|
||
it('without an end node the call is still a complete one: it just returns nothing', () => {
|
||
const fixture = makeProject();
|
||
const callee = makeBehavior(fixture, 'NoEnd');
|
||
const cEdit = graphEditor(fixture, callee);
|
||
const start = cEdit.addNode(BEHAVIOR_START);
|
||
cEdit.connect(start, 'exec', cEdit.addNode('Log', { message: 'sub' }), 'exec');
|
||
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const edit = graphEditor(fixture, caller);
|
||
const onStart = edit.addNode('OnStart');
|
||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||
edit.wire([
|
||
[onStart, 'exec', call, 'exec'],
|
||
[call, 'exec', edit.addNode('Log', { message: 'after' }), 'exec'],
|
||
]);
|
||
|
||
// A missing end node is not a defect, so nothing is reported.
|
||
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
|
||
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
|
||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
|
||
});
|
||
});
|
||
|
||
describe('a called behavior without a start node begins at On start (#230)', () => {
|
||
/** A caller whose On start runs `callee` and logs "after" once the call is done. */
|
||
function logCaller(fixture: ProjectFixture, callee: BehaviorAsset) {
|
||
const caller = makeBehavior(fixture, 'Caller');
|
||
const edit = graphEditor(fixture, caller);
|
||
const onStart = edit.addNode('OnStart');
|
||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||
edit.wire([
|
||
[onStart, 'exec', call, 'exec'],
|
||
[call, 'exec', edit.addNode('Log', { message: 'after' }), 'exec'],
|
||
]);
|
||
return { caller, call };
|
||
}
|
||
|
||
it('runs the flow behind its On start, and the call is not marked dead', () => {
|
||
const fixture = makeProject();
|
||
const callee = makeBehavior(fixture, 'Plain');
|
||
const cEdit = graphEditor(fixture, callee);
|
||
cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'sub' }), 'exec');
|
||
const { caller, call } = logCaller(fixture, callee);
|
||
|
||
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
|
||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
|
||
});
|
||
|
||
it('still hands back what its end node was given', () => {
|
||
const fixture = makeProject();
|
||
const callee = makeBehavior(fixture, 'Seven');
|
||
const cEdit = graphEditor(fixture, callee);
|
||
const onStart = cEdit.addNode('OnStart');
|
||
const end = cEdit.addNode(BEHAVIOR_END);
|
||
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
|
||
cEdit.wire([
|
||
[onStart, 'exec', end, 'exec'],
|
||
[cEdit.addNode('Number', { value: 7 }), 'value', end, 'out1'],
|
||
]);
|
||
const caller = makeBehavior(fixture, 'Caller', ['result']);
|
||
const edit = graphEditor(fixture, caller);
|
||
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||
const set = edit.variables(['result'], { write: true });
|
||
edit.wire([
|
||
[edit.addNode('OnStart'), 'exec', call, 'exec'],
|
||
[call, 'exec', set, 'exec'],
|
||
[call, 'out1', set, 'result'],
|
||
]);
|
||
|
||
expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7);
|
||
});
|
||
|
||
it('runs every On start, in graph order, as the behavior would on its own', () => {
|
||
const fixture = makeProject();
|
||
const callee = makeBehavior(fixture, 'Twice');
|
||
const cEdit = graphEditor(fixture, callee);
|
||
cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'one' }), 'exec');
|
||
cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'two' }), 'exec');
|
||
const { caller } = logCaller(fixture, callee);
|
||
|
||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['one', 'two', 'after']);
|
||
});
|
||
|
||
it('is only a fallback: with a start node, its On start stays out of the call', () => {
|
||
const fixture = makeProject();
|
||
const callee = makeBehavior(fixture, 'Both');
|
||
const cEdit = graphEditor(fixture, callee);
|
||
cEdit.connect(cEdit.addNode(BEHAVIOR_START), 'exec', cEdit.addNode('Log', { message: 'start' }), 'exec');
|
||
cEdit.connect(cEdit.addNode('OnStart'), 'exec', cEdit.addNode('Log', { message: 'on start' }), 'exec');
|
||
const { caller } = logCaller(fixture, callee);
|
||
|
||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['start', 'after']);
|
||
});
|
||
});
|
||
|
||
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, terminalNode(callee.graph, BEHAVIOR_START)!.id, '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('a node of an unknown type is refused with a CommandError, which the editor turns into a notice', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture);
|
||
const ports = [{ key: 'a', kind: 'number' as const }];
|
||
behavior.graph.nodes.push({ id: 'n', type: 'NoSuchNode', x: 0, y: 0, params: {}, ports });
|
||
expect(() => new RemovePortCommand(behavior.id, 'n', 'a').execute(fixture.project)).toThrow(CommandError);
|
||
});
|
||
|
||
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, terminalNode(callee.graph, BEHAVIOR_END)!.id, '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, graphLookup(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;
|
||
}
|
||
}
|