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

+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.