Files
feuerwerk/tests/core/nested-behavior.test.ts
T
adminandClaude Opus 5.5 21d7149756 Let a nested behavior without a start node run from On start (#230)
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>
2026-09-25 16:13:14 +02:00

391 lines
18 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 { 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;
}
}