Files
feuerwerk/tests/core/dynamic-ports.test.ts
T
rootandClaude Opus 5 7c63c884ef Replace the five variable nodes with one Variables node
A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:56:24 +02:00

160 lines
6.9 KiB
TypeScript

/** Ports a node works out for itself (`NodeDef.derivePorts`): from its settings, or from its own edges. */
import { describe, expect, it } from 'vitest';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import { DisconnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import {
BEHAVIOR_END,
BEHAVIOR_START,
type GraphNode,
GraphInterpreter,
inlineInputs,
requireNodeDef,
resolveDef,
} from '../../src/core/graph';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
function sequenceFixture() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
const edit = graphEditor(fixture, behavior);
return { ...fixture, behavior, edit };
}
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
resolveDef(node, behavior.graph, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
const edgeKeys = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort();
describe('a Sequence sized by what is plugged into it', () => {
it('starts with one output and grows a spare as each last one is connected', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
edit.connect(node, 'a', edit.addNode('Log'), 'exec');
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
edit.connect(node, 'b', edit.addNode('Log'), 'exec');
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
});
it('shrinks again when the bottom edge goes, and never below the last connected one', () => {
const fixture = sequenceFixture();
const { stack, behavior, edit } = fixture;
const node = edit.addNode('Sequence');
edit.wire(['a', 'b'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
stack.undo();
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
stack.undo();
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
});
it('keeps a gap rather than dropping an edge: the last connected port sets the size', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
edit.wire(['a', 'b', 'c'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
const middle = behavior.graph.edges.find((e) => e.from.port === 'b')!;
fixture.stack.execute(new DisconnectCommand(behavior.id, middle.id));
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c', 'd']);
expect(edgeKeys(behavior)).toEqual(['a→exec', 'c→exec']);
});
it('runs the outputs it has, in order', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
const onStart = edit.addNode('OnStart');
edit.connect(onStart, 'exec', node, 'exec');
for (const port of ['a', 'b'])
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['A', 'B']);
});
});
/** The point of a struct: a bundle crosses a behavior boundary on ONE edge. */
describe('a struct travelling through a nested behavior', () => {
it('goes in as one port and comes back out as its parts', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'Sum piece');
const inner = graphEditor(fixture, callee);
const start = inner.addNode(BEHAVIOR_START);
const end = inner.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'piece', kind: 'struct' }));
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'total', kind: 'number' }));
const expand = inner.addNode('ExpandStruct');
for (const key of ['x', 'y'])
fixture.stack.execute(new AddPortCommand(callee.id, expand.id, { key, kind: 'number' }));
const add = inner.addNode('Add');
inner.wire([
[start, 'exec', end, 'exec'],
[start, 'piece', expand, 'value'],
[expand, 'x', add, 'a'],
[expand, 'y', add, 'b'],
[add, 'result', end, 'total'],
]);
const caller = makeBehavior(fixture, 'Caller', ['result']);
const outer = graphEditor(fixture, caller);
const pack = outer.addNode('PackStruct', { x: 30, y: 12 });
for (const key of ['x', 'y'])
fixture.stack.execute(new AddPortCommand(caller.id, pack.id, { key, kind: 'number' }));
const onStart = outer.addNode('OnStart');
const call = outer.addNode('RunBehavior', { behavior: callee.id });
const set = outer.variables(['result'], { write: true });
outer.wire([
[onStart, 'exec', call, 'exec'],
[pack, 'value', call, 'piece'],
[call, 'exec', set, 'exec'],
[call, 'total', set, 'result'],
]);
// One edge into the call, where two numbers would need two.
expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1);
const ctx = fakeContext({ variables: { result: 0 } });
new GraphInterpreter(caller.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.result).toBe(42);
});
});
describe('a struct input', () => {
it('is offered no inline field, because a bundle cannot be typed on one line', () => {
const def = requireNodeDef('ExpandStruct');
expect(def.inputs.map((p) => p.type)).toEqual(['struct']);
expect(inlineInputs(def)).toEqual([]);
});
});
describe('a Run behavior node whose behavior changes', () => {
it('drops the edges of the ports the new behavior does not have', () => {
const fixture = makeProject();
const called = makeBehavior(fixture, 'Called');
const other = makeBehavior(fixture, 'Other');
const start = graphEditor(fixture, called).addNode(BEHAVIOR_START);
fixture.stack.execute(new AddPortCommand(called.id, start.id, { key: 'power', kind: 'number' }));
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
const call = edit.addNode('RunBehavior', { behavior: called.id });
const number = edit.addNode('Number', { value: 3 });
edit.connect(number, 'value', call, 'power');
expect(edgeKeys(caller)).toEqual(['value→power']);
fixture.stack.execute(new SetNodeParamCommand(caller.id, call.id, 'behavior', other.id));
expect(edgeKeys(caller)).toEqual([]);
fixture.stack.undo();
expect(edgeKeys(caller)).toEqual(['value→power']);
expect(start.ports).toEqual([{ key: 'power', kind: 'number' }]);
});
});