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.
This commit is contained in:
root committed 2026-09-10 08:32:14 +02:00
1 parent b6a609ae24
commit 3c76106fd2
15 files changed
+159 -111

No files matched your search

+48 -30
View File
@@ -1,11 +1,10 @@
/** Ports a node grows from its own settings (`NodeDef.derivePorts`), and what happens to the edges when it shrinks. */
/** 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 { SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { DisconnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import {
BEHAVIOR_END,
BEHAVIOR_START,
findNode,
type GraphNode,
GraphInterpreter,
inlineInputs,
@@ -23,43 +22,62 @@ function sequenceFixture() {
return { ...fixture, behavior, edit };
}
const portsOf = (fixture: ProjectFixture, node: GraphNode) =>
resolveDef(node, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
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 its count', () => {
it('offers three outputs by default and grows one port per step', () => {
const { stack, project, behavior, edit } = sequenceFixture();
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({ project, stack }, node)).toEqual(['a', 'b', 'c']);
stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 6));
expect(portsOf({ project, stack }, findNode(behavior.graph, node.id)!)).toEqual([
'a',
'b',
'c',
'd',
'e',
'f',
]);
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('never falls below two outputs, because one output is a plain link', () => {
const { stack, project, behavior, edit } = sequenceFixture();
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');
stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 1));
expect(portsOf({ project, stack }, findNode(behavior.graph, node.id)!)).toEqual(['a', 'b']);
});
edit.wire(['a', 'b'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
it('takes the edges of the outputs it drops, and gives them back on undo', () => {
const { stack, behavior, edit } = sequenceFixture();
const node = edit.addNode('Sequence', { count: 4 });
edit.wire(['a', 'b', 'c', 'd'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'count', 2));
expect(edgeKeys(behavior)).toEqual(['a→exec', 'b→exec']);
stack.undo();
expect(edgeKeys(behavior)).toEqual(['a→exec', 'b→exec', 'c→exec', 'd→exec']);
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']);
});
});
+3 -3
View File
@@ -73,7 +73,7 @@ describe('a behavior used as a node', () => {
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, behaviorLookup(fixture.project))!;
const def = resolveDef(call, caller.graph, behaviorLookup(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".
@@ -214,11 +214,11 @@ describe('a call that cannot do anything says so', () => {
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 signature = () => portSignature(call, caller.graph, 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(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare');
expect(signature()).not.toBe(healthy);
});
+18 -18
View File
@@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
interface Case {
/** What the case demonstrates – becomes the failure message. */
@@ -16,6 +16,8 @@ interface Case {
context?: Partial<NodeContext>;
/** Expected data outputs, compared exactly. */
outputs?: Record<string, Value>;
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
derivedOutputs?: string[];
/** Expected exec continuation; `null` asserts the flow ends here. */
next?: string | string[] | null;
/** Expected loop request; `null` asserts the node does not loop. */
@@ -28,7 +30,10 @@ interface Case {
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
const ctx = fakeContext(c.context);
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
const registered = requireNodeDef(type);
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
};
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
@@ -47,9 +52,8 @@ const CASES: Record<string, Case[]> = {
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
],
Sequence: [
{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] },
{ name: 'a raised count runs that many', params: { count: 5 }, next: ['a', 'b', 'c', 'd', 'e'] },
{ name: 'two is the floor: one output is a plain link', params: { count: 0 }, next: ['a', 'b'] },
{ name: 'on its own it has the one output, and follows it', next: ['a'] },
{ name: 'runs every output it has grown, in order', derivedOutputs: ['b', 'c'], next: ['a', 'b', 'c'] },
],
BehaviorEnd: [
{
@@ -706,48 +710,44 @@ for (const [type, cases] of Object.entries(CASES)) {
describe('action nodes write through to the object they are given', () => {
it('Move applies dx/dy × scale × dt', () => {
const target = at('m', 0, 0);
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
runDef('Move', fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
});
it('SetPosition places the object absolutely', () => {
const target = at('p', 10, 10);
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
runDef('SetPosition', fakeContext(), { object: target, x: 30, y: -40 }, {});
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
});
it('SetProperty keeps the type the property already had', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(
fakeContext(),
{ object: target, value: '250' },
{ property: 'width' },
);
runDef('SetProperty', fakeContext(), { object: target, value: '250' }, { property: 'width' });
expect(target.props.width).toBe(250);
});
it('SetProperty without a property name writes nothing', () => {
const target = fakeHandle({ width: 100 });
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
runDef('SetProperty', fakeContext(), { object: target, value: 5 }, {});
expect(target.props).toEqual({ width: 100 });
});
it('SetEnabled switches the object off', () => {
const target = fakeHandle({ enabled: true });
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
runDef('SetEnabled', fakeContext(), { object: target, enabled: false }, {});
expect(target.props.enabled).toBe(false);
});
it('SetText writes the text property, stringifying numbers', () => {
const target = fakeHandle({ text: 'a' });
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
runDef('SetText', fakeContext(), { object: target, text: 12 }, {});
expect(target.props.text).toBe('12');
});
it('Destroy hands the object to the context', () => {
const removed: string[] = [];
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {});
expect(removed).toEqual(['Coin']);
});
@@ -758,7 +758,7 @@ describe('action nodes write through to the object they are given', () => {
restarted = true;
},
});
requireNodeDef('RestartGame').run!(ctx, {}, {});
runDef('RestartGame', ctx, {}, {});
expect(restarted).toBe(true);
});
@@ -766,7 +766,7 @@ describe('action nodes write through to the object they are given', () => {
const ctx = fakeContext();
const draws = Array.from(
{ length: 200 },
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
() => runDef('Random', ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
);
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
// Not a constant: a generator stuck on one value would pass the range check above.
+13 -1
View File
@@ -11,6 +11,7 @@ import {
type PointerState,
type Value,
requireNodeDef,
type RunResult,
} from '../src/core/graph';
import type { BehaviorAsset, SceneAsset } from '../src/core/model/Asset';
import { findObject, worldPosition } from '../src/core/model/hierarchy';
@@ -176,6 +177,17 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
return ctx;
}
/** Runs a node on its registered definition: the shape it has while sitting in no graph. */
export function runDef(
type: string,
ctx: NodeContext,
inputs: Record<string, Value>,
params: PropertyBag = {},
): RunResult {
const def = requireNodeDef(type);
return def.run!(ctx, inputs, params, def);
}
/** Runs one node definition in isolation and returns its data outputs. */
export function runNode(
type: string,
@@ -183,7 +195,7 @@ export function runNode(
params: PropertyBag = {},
ctx: NodeContext = fakeContext(),
): Record<string, Value> {
return requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {};
return runDef(type, ctx, inputs, params).outputs ?? {};
}
export interface WorldOptions {