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

+10 -8
View File
@@ -126,14 +126,16 @@ shown on the node and editable in the inspector.
inspector fields are generated from it. A port may declare a `default` for its inline value
(e.g. `Move.scale = 1`), shown on the node and in the inspector.
- **Ports that are not fixed by the type**: a `NodeDef` may say `declaresPorts` (the node carries
its own list, edited in the inspector) or `derivePorts` (the node computes them from its
settings). `resolveDef(node, lookup)` is what applies both, so the canvas and the inspector see
a definition that fits *this* node. `Sequence` counts its outputs from an **Outputs** setting
(2…26, keys `a`, `b`, `c`, … – the old three keep their edges), `Run behavior` takes its ports
from the behavior it names, and a behavior's `Start`/`End` node declares them. Turning a number
down, or pointing a call at a different behavior, **takes the edges of the ports that go away**
and gives them back on undo: an edge on a port that no longer exists draws as nothing but
survives every save, and would come back to life once the port returns.
its own list, edited in the inspector) or `derivePorts` (the node works them out from its
settings and from the graph around it). `resolveDef(node, graph, lookup)` is what applies both,
so the canvas and the inspector see a definition that fits *this* node; a node whose ports are
derived reads them back off that definition, which is why `run` is handed it. `Sequence` keeps
one free output below the last connected one – connecting the spare grows it, unplugging the
bottom edge shrinks it again (keys `a`, `b`, `c`, … up to 26). `Run behavior` takes its ports
from the behavior it names, and a behavior's `Start`/`End` node declares them. Pointing a call
at a different behavior **takes the edges of the ports that go away** and gives them back on
undo: an edge on a port that no longer exists draws as nothing but survives every save, and
would come back to life once the port returns.
- **Structs**: several named values travelling as one, so a bundle needs one edge instead of one
per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, and a
behavior port may be declared `struct` – which is how a bundle crosses a call. Fields are
+3 -3
View File
@@ -136,7 +136,7 @@ export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
super.execute(project);
const graph = requireBehavior(project, this.assetId).graph;
const node = requireNode(project, this.assetId, this.nodeId);
const def = resolveDef(node, behaviorLookup(project));
const def = resolveDef(node, graph, behaviorLookup(project));
if (!def) return;
const outputs = new Set(def.outputs.map((p) => p.key));
const inputs = new Set(def.inputs.map((p) => p.key));
@@ -189,8 +189,8 @@ export class ConnectCommand implements Command {
if (!fromNode || !toNode) throw new CommandError('Node not found');
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
const lookup = behaviorLookup(project);
const out = resolveDef(fromNode, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
const inp = resolveDef(toNode, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
const out = resolveDef(fromNode, graph, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
const inp = resolveDef(toNode, graph, lookup)?.inputs.find((p) => p.key === this.edge.to.port);
if (!out || !inp) throw new CommandError('Port not found');
if (!portsCompatible(out.type, inp.type))
throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
+3 -3
View File
@@ -95,7 +95,7 @@ export class GraphInterpreter {
const def = this.def(node);
if (!def?.run) return;
const inputs = this.collectInputs(node, ctx);
const result = def.run(ctx, inputs, node.params);
const result = def.run(ctx, inputs, node.params, def);
const returned = result.call ? this.call(result.call, ctx, depth) : {};
this.frame.outputCache.set(node.id, { ...result.outputs, ...returned });
this.trace?.onNode(node.id);
@@ -174,14 +174,14 @@ export class GraphInterpreter {
throw new Error('Behavior graph exceeded the step limit (data cycle?)');
// Mark before evaluating so a data cycle reads null instead of recursing forever.
this.frame.dataCache.set(nodeId, {});
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params, def).outputs ?? {};
this.frame.dataCache.set(nodeId, outputs);
this.trace?.onNode(nodeId);
return outputs[port] ?? null;
}
private def(node: GraphNode) {
return resolveDef(node, this.behaviors);
return resolveDef(node, this.frame.graph, this.behaviors);
}
private node(id: string): GraphNode | undefined {
+7 -4
View File
@@ -1,6 +1,6 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { PropertyDef } from '../properties/PropertySchema';
import type { PortType } from './Graph';
import type { Graph, GraphNode, PortType } from './Graph';
import type { BehaviorLookup } from './lookup';
import type { NodeContext } from './NodeContext';
import { asBoolean, asNumber, asString, type Value } from './values';
@@ -42,9 +42,10 @@ export interface RunResult {
call?: CallResult;
}
/** What a node may compute its ports from: its own settings, and the behaviors around it. */
/** What a node may compute its ports from: itself, the graph it sits in, and the behaviors around it. */
export interface PortSource {
params: PropertyBag;
node: GraphNode;
graph: Graph;
behaviors: BehaviorLookup;
}
@@ -83,8 +84,10 @@ export interface NodeDef {
/**
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
* `def` is this definition as resolved for the node at hand, which is the only way a node with
* derived ports can know how many it has.
*/
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
}
const defs = new Map<string, NodeDef>();
+2 -2
View File
@@ -12,7 +12,7 @@ export interface PortSide {
export function portTypeOf(graph: Graph, side: PortSide, lookup: BehaviorLookup): PortType | undefined {
const node = findNode(graph, side.nodeId);
const def = node && resolveDef(node, lookup);
const def = node && resolveDef(node, graph, lookup);
const list = side.isOutput ? def?.outputs : def?.inputs;
return list?.find((p) => p.key === side.port)?.type;
}
@@ -40,7 +40,7 @@ export function dropTargetPort(
): PortSide | null {
if (nodeId === from.nodeId) return null;
const node = findNode(graph, nodeId);
const def = node && resolveDef(node, lookup);
const def = node && resolveDef(node, graph, lookup);
const fromType = portTypeOf(graph, from, lookup);
if (!def || fromType === undefined) return null;
const candidates = compatiblePorts(def, fromType, from.isOutput)
+4 -4
View File
@@ -41,12 +41,12 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
* The node definition as it applies to *this* node: identical to the registered one, unless the
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
*/
export function resolveDef(node: GraphNode, lookup: BehaviorLookup): NodeDef | undefined {
export function resolveDef(node: GraphNode, graph: Graph, lookup: BehaviorLookup): NodeDef | undefined {
const def = nodeDef(node.type);
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
const own = def.declaresPorts ? (node.ports ?? []).map(portDefOf) : [];
const side = (which: 'inputs' | 'outputs') => (def.declaresPorts === which ? own : []);
const grown = def.derivePorts?.({ params: node.params, behaviors: lookup }) ?? {};
const grown = def.derivePorts?.({ node, graph, behaviors: lookup }) ?? {};
return {
...def,
label: grown.label ?? def.label,
@@ -74,8 +74,8 @@ export function callProblem(node: GraphNode, lookup: BehaviorLookup, ownerId = '
* Everything that changes how a node looks, as one string. Views and the inspector rebuild when it
* changes – a port list that is derived rather than fixed has no other way of being noticed.
*/
export function portSignature(node: GraphNode, lookup: BehaviorLookup, ownerId = ''): string {
const def = resolveDef(node, lookup);
export function portSignature(node: GraphNode, graph: Graph, lookup: BehaviorLookup, ownerId = ''): string {
const def = resolveDef(node, graph, lookup);
if (!def) return 'missing';
const list = (ports: PortDef[]) => ports.map((p) => `${p.key}:${p.type}`).join(',');
return `${node.type}|${def.label}|${list(def.inputs)}|${list(def.outputs)}|${
+2 -2
View File
@@ -18,8 +18,8 @@ export const RunBehavior: NodeDef = {
inputs: [exec()],
outputs: [exec()],
params: [{ key: BEHAVIOR_PARAM, label: 'Behavior', kind: 'asset', default: '', assetTypes: ['behavior'] }],
derivePorts: (source) => {
const program = source.behaviors(textOf(source.params[BEHAVIOR_PARAM] ?? ''));
derivePorts: ({ node, behaviors }) => {
const program = behaviors(textOf(node.params[BEHAVIOR_PARAM] ?? ''));
return {
// The graph reads as what it does, not as a row of identical "Run behavior" boxes.
label: program?.name,
+24 -21
View File
@@ -1,21 +1,28 @@
import type { PropertyBag } from '../../../model/Properties';
import type { Graph, GraphNode } from '../../Graph';
import type { NodeDef } from '../../NodeRegistry';
import { asNumber } from '../../values';
import { exec } from '../shared';
const COUNT = 'count';
/** Below two outputs a Sequence is just a link, so two are fixed and the rest are derived. */
const FIXED = 2;
/** One letter per output, so `a` stays `a` – the alphabet is what sets the ceiling. */
const MAX = 26;
export function sequenceKey(index: number): string {
function sequenceKey(index: number): string {
return String.fromCharCode(97 + index);
}
export function sequenceCount(params: PropertyBag): number {
const count = Math.round(asNumber(params[COUNT] ?? 3));
return Math.max(FIXED, Math.min(MAX, count));
function sequenceIndex(key: string): number {
return key.length === 1 ? key.charCodeAt(0) - 97 : -1;
}
/**
* One free output below the last connected one: plugging into the spare grows the node, unplugging
* the bottom edge shrinks it again. No output can vanish while an edge hangs on it, which is why
* this counts the last *connected* port rather than the connected ones.
*/
function outputCount(node: GraphNode, graph: Graph): number {
let last = -1;
for (const edge of graph.edges)
if (edge.from.node === node.id) last = Math.max(last, sequenceIndex(edge.from.port));
return Math.min(MAX, last + 2);
}
function outputAt(index: number) {
@@ -23,23 +30,19 @@ function outputAt(index: number) {
}
/**
* The keys are `a`, `b`, `c`, … rather than `out1`…`outN`: those three are what every Sequence
* saved so far names its outputs, and renaming them would drop the edges hanging on them.
* The keys are `a`, `b`, `c`, … rather than `out1`…`outN`: that is what every Sequence saved so far
* names its outputs, and renaming them would drop the edges hanging on them.
*/
export const Sequence: NodeDef = {
type: 'Sequence',
label: 'Sequence',
category: 'flow',
description: 'Runs its outputs one after another.',
description: 'Runs its outputs one after another. A new output appears as you connect the last one.',
inputs: [exec()],
outputs: [outputAt(0), outputAt(1)],
params: [
{ key: COUNT, label: 'Outputs', kind: 'number', default: 3, min: FIXED, max: MAX, step: 1, decimals: 0 },
],
derivePorts: ({ params }) => ({
outputs: Array.from({ length: sequenceCount(params) - FIXED }, (_, i) => outputAt(FIXED + i)),
}),
run: (_ctx, _inputs, params) => ({
next: Array.from({ length: sequenceCount(params) }, (_, i) => sequenceKey(i)),
outputs: [outputAt(0)],
params: [],
derivePorts: ({ node, graph }) => ({
outputs: Array.from({ length: outputCount(node, graph) - 1 }, (_, i) => outputAt(i + 1)),
}),
run: (_ctx, _inputs, _params, def) => ({ next: def.outputs.map((p) => p.key) }),
};
+1 -1
View File
@@ -235,7 +235,7 @@ export class GraphCanvas extends Widget {
const seen = new Set<string>();
const lookup = behaviorLookup(this.state.project);
for (const node of asset.graph.nodes) {
const def = resolveDef(node, lookup);
const def = resolveDef(node, asset.graph, lookup);
if (!def) continue;
seen.add(node.id);
let view = this.views.get(node.id);
+3 -1
View File
@@ -1,6 +1,7 @@
import {
allNodeDefs,
BEHAVIOR_PARAM,
createEmptyGraph,
isTerminalType,
type NodeDef,
resolveDef,
@@ -39,7 +40,8 @@ export function paletteEntries(project: Project, asset: BehaviorAsset): PaletteE
// A behavior running itself is an endless loop, so it is not on offer.
if (other.type !== 'behavior' || other.id === asset.id) continue;
const params = { [BEHAVIOR_PARAM]: other.id };
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, lookup);
// The entry is a preview of a node that sits in no graph yet, hence the empty one.
const def = resolveDef({ id: '', type: RUN_BEHAVIOR, x: 0, y: 0, params }, createEmptyGraph(), lookup);
if (def) entries.push({ id: `${RUN_BEHAVIOR}:${other.id}`, hint: 'behavior', def, params });
}
return entries;
+18 -10
View File
@@ -1,6 +1,7 @@
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
callProblem,
type Graph,
edgesInto,
type GraphNode,
inlineInputDef,
@@ -25,14 +26,14 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const node = asset.graph.nodes.find((n) => n.id === nodeId);
if (!node) return 'missing';
const lookup = behaviorLookup(project);
const def = resolveDef(node, lookup);
const def = resolveDef(node, asset.graph, lookup);
const connected = def
? inlineInputs(def)
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
.map((p) => p.key)
: [];
const declared = (node.ports ?? []).map((p) => `${p.key}:${p.kind}`).join(',');
return `${portSignature(node, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(',')}|${asset.variables
.map((v) => v.key)
.join(',')}`;
}
@@ -42,7 +43,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
const asset = findAsset(host.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
const def = node && resolveDef(node, asset.graph, behaviorLookup(host.state.project));
if (!node || !def) return;
addTitle(host, `${def.category.toUpperCase()} NODE`);
@@ -90,9 +91,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId: string): boolean {
const node = currentNode(host.state.project, assetId, nodeId);
const def = node && resolveDef(node, behaviorLookup(host.state.project));
if (!node || !def) return false;
const found = currentNode(host.state.project, assetId, nodeId);
const def = found && resolveDef(found.node, found.graph, behaviorLookup(host.state.project));
if (!found || !def) return false;
for (const [key, row] of host.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const port = def.inputs.find((p) => p.key === name);
@@ -100,7 +101,7 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
scope === 'param'
? def.params.find((p) => p.key === name)?.default
: port && inlineInputDef(port).default;
row.setValue(node.params[name] ?? fallback ?? '');
row.setValue(found.node.params[name] ?? fallback ?? '');
}
return true;
}
@@ -117,7 +118,7 @@ function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
/** Previews straight into the node's params; the command records the value from before the preview. */
function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers {
return previewingHandlers<GraphNode>(host, {
target: () => currentNode(host.state.project, assetId, nodeId),
target: () => currentNode(host.state.project, assetId, nodeId)?.node,
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
@@ -125,7 +126,14 @@ function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key
});
}
function currentNode(project: Project, assetId: string, nodeId: string): GraphNode | undefined {
/** The node together with the graph it sits in, because its ports are derived from both. */
function currentNode(
project: Project,
assetId: string,
nodeId: string,
): { node: GraphNode; graph: Graph } | undefined {
const asset = findAsset(project, assetId);
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
if (asset?.type !== 'behavior') return undefined;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
return node && { node, graph: asset.graph };
}
+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 {