Make a call node say why it does nothing, and easier to place

A behavior node that cannot run looked exactly like a healthy one: no
start node, no behavior picked, a deleted behavior or a self-call were
all silently skipped. The call still had to be forgiving at runtime -- a
dangling reference must not abort a game -- so the fix is diagnosis, not
an exception: describeProblem() names the one reason, the node draws it
in red under its title, and the inspector repeats it where you pick the
behavior. A missing end node is deliberately not a problem: a call is
always synchronous, and the end node only decides which values come back.

Two ways in: a behavior dragged from the asset panel lands in the graph
under the pointer, and double-clicking a call node opens the graph it
runs. Both reuse machinery that already existed -- the asset panel
already starts the drag, and list views already report double clicks.

The e2e module needed its assets at the head of the asset list: after 18
modules the list is long enough that a newly appended row scrolls out of
view, and the press then lands on the panel behind it, starting no drag
at all. That looked exactly like a rejected drop and cost two full runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 20:40:56 +02:00
1 parent 1d8e6ebea0
commit c6f907b4e0
12 files changed
+427 -24

No files matched your search

+66 -1
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
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,
@@ -180,6 +182,69 @@ describe('a behavior used as a node', () => {
});
});
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 = () => behaviorLookup(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 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, behaviorLookup(fixture.project), caller.id);
const healthy = signature();
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(resolveDef(call, behaviorLookup(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, behaviorLookup(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('declared ports', () => {
it('renaming one rewrites the edges in this graph and in every calling one', () => {
const fixture = makeProject();