diff --git a/README.md b/README.md index dbf7bfbf..ad809497 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index ffebeb30..b53105bc 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -136,7 +136,7 @@ export class SetNodeParamCommand extends SetBagValueCommand { 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}`); diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 5a0452a6..a044580c 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -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 { diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 5bb42c17..4d59e5c8 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -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, params: PropertyBag): RunResult; + run?(ctx: NodeContext, inputs: Record, params: PropertyBag, def: NodeDef): RunResult; } const defs = new Map(); diff --git a/src/core/graph/dropTarget.ts b/src/core/graph/dropTarget.ts index 7c88e1a5..79a54485 100644 --- a/src/core/graph/dropTarget.ts +++ b/src/core/graph/dropTarget.ts @@ -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) diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 64e2c44d..8a21ae5e 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -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)}|${ diff --git a/src/core/graph/nodes/flow/RunBehavior.ts b/src/core/graph/nodes/flow/RunBehavior.ts index a5e6a5a9..ed16327d 100644 --- a/src/core/graph/nodes/flow/RunBehavior.ts +++ b/src/core/graph/nodes/flow/RunBehavior.ts @@ -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, diff --git a/src/core/graph/nodes/flow/Sequence.ts b/src/core/graph/nodes/flow/Sequence.ts index 6c23a292..f3200dd7 100644 --- a/src/core/graph/nodes/flow/Sequence.ts +++ b/src/core/graph/nodes/flow/Sequence.ts @@ -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) }), }; diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 57548b3d..a8302060 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -235,7 +235,7 @@ export class GraphCanvas extends Widget { const seen = new Set(); 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); diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 1887dc8b..7a6696b2 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -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; diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 834e25e4..e436137d 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -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(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 }; } diff --git a/tests/core/dynamic-ports.test.ts b/tests/core/dynamic-ports.test.ts index e4030b5a..ed6b5d06 100644 --- a/tests/core/dynamic-ports.test.ts +++ b/tests/core/dynamic-ports.test.ts @@ -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']); }); }); diff --git a/tests/core/nested-behavior.test.ts b/tests/core/nested-behavior.test.ts index 95260d3b..d9d73fbe 100644 --- a/tests/core/nested-behavior.test.ts +++ b/tests/core/nested-behavior.test.ts @@ -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); }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 9645e54d..e08add2f 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -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; /** Expected data outputs, compared exactly. */ outputs?: Record; + /** 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 = { { 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. diff --git a/tests/helpers.ts b/tests/helpers.ts index a9471cbc..2b445674 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -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 = {}): 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, + 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 { - return requireNodeDef(type).run!(ctx, inputs, params).outputs ?? {}; + return runDef(type, ctx, inputs, params).outputs ?? {}; } export interface WorldOptions {