Files
feuerwerk/tests/core/dynamic-ports.test.ts
T
root 3c76106fd2 Let a Sequence grow an output as you connect the last one
The Outputs setting is gone. A Sequence now keeps one free output below the
last connected one: nothing plugged in is one output, connecting the spare
grows a new one, unplugging the bottom edge shrinks it back.

It counts the last *connected* port rather than the number of edges, so
unplugging a middle one leaves a gap instead of silently dropping an edge.

Removing the setting also removes a bug it caused. Typing in the Outputs field
did nothing and the arrow keys did nothing, while dragging the value worked:
onInput fires per keystroke, the preview writes into node.params, that emits
'project', and Inspector.refreshValues() sees the node's signature change --
the port list is part of it -- so it rebuilds and destroys the row being typed
in. A drag survives because it keeps receiving globalpointermove on the old
widget. refreshValues() has no focus guard where refreshLive() does; the trap
is still there for the next typed field that changes a node's shape.

Ports that depend on the edges around a node mean derivePorts needs the graph,
not just the params, so resolveDef and portSignature take it too. And a node
whose ports are derived cannot know how many it has from its type, so run is
handed the definition resolved for it.
2026-09-10 08:32:14 +02:00

163 lines
7.0 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. Tetris carries the same
* state today as eight parallel ports wired by hand, which is the pain this replaces.
*/
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.addNode('SetVariable', { variable: 'result' });
outer.wire([
[onStart, 'exec', call, 'exec'],
[pack, 'value', call, 'piece'],
[call, 'exec', set, 'exec'],
[call, 'total', set, 'value'],
]);
// 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' }]);
});
});