Replace the five variable nodes with one Variables node

A single node now lists any number of variables. Every entry is an output
that reads the current value whenever it is pulled; an entry switched to
Set is also an input and is written when the flow arrives. Exec pins only
exist while at least one entry writes, and all inputs are read before any
write, so one node can swap two variables.

Get/Set variable, Get/Set variables and Variable are gone. Old project
files are converted on load: each old node becomes a Variables node with
the same variables, edges and typed values.

A written port without a typed value now shows the zero of its kind on
the node body, matching what the inspector shows and what the run writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-11 16:56:24 +02:00
1 parent 854ae2e756
commit 7c63c884ef
40 files changed
+895 -590

No files matched your search

+202 -45
View File
@@ -1,61 +1,69 @@
import { describe, expect, it } from 'vitest';
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter, resolveDef } from '../../src/core/graph';
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type GraphNode, GraphInterpreter, inlineValue, resolveDef } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
function setup() {
function setup(variables = ['count', 'copy']) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['count', 'copy']);
const behavior = makeBehavior(fixture, 'B', variables);
return { fixture, behavior, edit: graphEditor(fixture, behavior) };
}
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, count = 5) {
const ctx = fakeContext({ variables: { count, copy: 0 } });
function run(
behavior: BehaviorAsset,
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { count: 5, copy: 0 },
) {
const ctx = fakeContext({ variables: { ...variables } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
return ctx.variables;
}
const titleOf = (behavior: BehaviorAsset, index: number) =>
resolveDef(behavior.graph.nodes[index]!, behavior.graph, () => null)!.label;
const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
resolveDef(node, behavior.graph, () => null)![side].map((p) => p.key);
describe('the Variable node', () => {
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
describe('the Variables node', () => {
it('can be read without a flow ever reaching it', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
const read = edit.variables(['count']);
const store = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
[start, 'exec', store, 'exec'],
[read, 'count', store, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 5, copy: 5 });
});
it('writes its input when the flow arrives, unconnected as well as through an edge', () => {
const { fixture, behavior, edit } = setup();
it('writes the variables switched to Set, typed in or through an edge, and leaves the rest', () => {
const { fixture, behavior, edit } = setup(['count', 'copy', 'kept']);
const start = edit.addNode('OnStart');
const inline = edit.addNode('Variable', { variable: 'count', value: 7 });
const number = edit.addNode('Number', { value: 3 });
const wired = edit.addNode('Variable', { variable: 'copy' });
const node = edit.variables(['count', 'copy', 'kept'], {
write: ['count', 'copy'],
params: { count: 7 },
});
edit.wire([
[start, 'exec', inline, 'exec'],
[inline, 'exec', wired, 'exec'],
[number, 'value', wired, 'value'],
[start, 'exec', node, 'exec'],
[number, 'value', node, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 7, copy: 3 });
expect(run(behavior, fixture, { count: 5, copy: 0, kept: 4 })).toEqual({ count: 7, copy: 3, kept: 4 });
});
it('counts up through its own output: count = count + 1', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count' });
const count = edit.variables(['count'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'value', add, 'a'],
[add, 'result', variable, 'value'],
[start, 'exec', count, 'exec'],
[count, 'count', add, 'a'],
[add, 'result', count, 'count'],
]);
expect(run(behavior, fixture).count).toBe(6);
});
@@ -63,40 +71,189 @@ describe('the Variable node', () => {
it('is read at the moment it is asked, so it sees a later write by another node', () => {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const variable = edit.addNode('Variable', { variable: 'count', value: 7 });
const overwrite = edit.addNode('SetVariable', { variable: 'count', value: 9 });
const copy = edit.addNode('SetVariable', { variable: 'copy' });
const count = edit.variables(['count'], { write: true, params: { count: 7 } });
const overwrite = edit.variables(['count'], { write: true, params: { count: 9 } });
const copy = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', variable, 'exec'],
[variable, 'exec', overwrite, 'exec'],
[start, 'exec', count, 'exec'],
[count, 'exec', overwrite, 'exec'],
[overwrite, 'exec', copy, 'exec'],
[variable, 'value', copy, 'value'],
[count, 'count', copy, 'copy'],
]);
expect(run(behavior, fixture)).toEqual({ count: 9, copy: 9 });
});
/** `Get variables` answered once per pulse; one node read on both sides of a write gave the old value twice. */
it('reaches across a write of one of its variables', () => {
const { fixture, behavior, edit } = setup(['px', 'before', 'after']);
const start = edit.addNode('OnStart');
const read = edit.variables(['px']);
const before = edit.variables(['before'], { write: true });
const write = edit.variables(['px'], { write: true, params: { px: 7 } });
const after = edit.variables(['after'], { write: true });
edit.wire([
[start, 'exec', before, 'exec'],
[before, 'exec', write, 'exec'],
[write, 'exec', after, 'exec'],
[read, 'px', before, 'before'],
[read, 'px', after, 'after'],
]);
expect(run(behavior, fixture, { px: 1, before: 0, after: 0 })).toEqual({ px: 7, before: 1, after: 7 });
});
it('reads every input before it writes any, so one node swaps two variables', () => {
const { fixture, behavior, edit } = setup(['a', 'b']);
const start = edit.addNode('OnStart');
const read = edit.variables(['a', 'b']);
const swap = edit.variables(['a', 'b'], { write: true });
edit.wire([
[start, 'exec', swap, 'exec'],
[read, 'b', swap, 'a'],
[read, 'a', swap, 'b'],
]);
expect(run(behavior, fixture, { a: 1, b: 2 })).toEqual({ a: 2, b: 1 });
});
it('carries the value it writes when nothing is typed, so the node can show it', () => {
const { fixture, behavior, edit } = setup();
for (const [key, kind] of [
['label', 'text'],
['on', 'boolean'],
['items', 'list'],
] as const)
fixture.stack.execute(new AddVariableCommand(behavior.id, key, kind));
const start = edit.addNode('OnStart');
const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } });
edit.connect(start, 'exec', node, 'exec');
const carried = resolveDef(node, behavior.graph, () => null)!
.inputs.filter((p) => p.type !== 'exec')
.map((p) => [p.key, inlineValue(node, p)]);
expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] });
expect(run(behavior, fixture).count).toBe(0);
});
it('has flow pins only while one of its variables is set', () => {
const { behavior, edit } = setup();
const read = edit.variables(['count', 'copy']);
const mixed = edit.variables(['count', 'copy'], { write: ['copy'] });
expect([ports(behavior, read, 'inputs'), ports(behavior, read, 'outputs')]).toEqual([
[],
['count', 'copy'],
]);
expect([ports(behavior, mixed, 'inputs'), ports(behavior, mixed, 'outputs')]).toEqual([
['exec', 'copy'],
['exec', 'count', 'copy'],
]);
});
});
describe('variable node titles', () => {
it('name the picked variable, and keep the plain title until one is picked', () => {
describe('switching Set on a Variables node', () => {
function wired() {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const number = edit.addNode('Number', { value: 3 });
const node = edit.variables(['count', 'copy'], { write: true, params: { count: 7 } });
const next = edit.variables(['copy'], { write: true });
edit.wire([
[start, 'exec', node, 'exec'],
[node, 'exec', next, 'exec'],
[number, 'value', node, 'copy'],
[node, 'count', next, 'copy'],
]);
return { fixture, behavior, node };
}
it('off takes the input with its edge and typed value, and undo brings both back', () => {
const { fixture, behavior, node } = wired();
const edges = structuredClone(behavior.graph.edges);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
expect(ports(behavior, node, 'inputs')).toEqual(['exec', 'count']);
expect(edgeCount(behavior)).toBe(3);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(node.params.count).toBeUndefined();
fixture.stack.undo();
fixture.stack.undo();
expect(node.params.count).toBe(7);
expect(behavior.graph.edges).toEqual(edges);
});
it('off on the last one takes the flow pins and their edges along, and undo restores the order', () => {
const { fixture, behavior, node } = wired();
const edges = structuredClone(behavior.graph.edges);
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(ports(behavior, node, 'inputs')).toEqual([]);
expect(ports(behavior, node, 'outputs')).toEqual(['count', 'copy']);
// Only the read of `count` is left: the flow in and out of the node went with its pins.
expect(behavior.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['count>copy']);
fixture.stack.undo();
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
/** Edges of both pins and of the flow, interleaved in the list: undo has to put each back in its place. */
function interleaved() {
const { fixture, behavior, edit } = setup();
const start = edit.addNode('OnStart');
const number = edit.addNode('Number', { value: 3 });
const node = edit.variables(['count'], { write: true });
const log = edit.addNode('Log');
edit.wire([
[start, 'exec', node, 'exec'],
[node, 'count', log, 'message'],
[number, 'value', node, 'count'],
[node, 'exec', log, 'exec'],
]);
return { fixture, behavior, node, edges: structuredClone(behavior.graph.edges) };
}
it('off on the only one takes its edge and the flow in one step, and undo restores the order', () => {
const { fixture, behavior, node, edges } = interleaved();
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
expect(behavior.graph.edges.map((e) => e.to.port)).toEqual(['message']);
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
it('removing the last written variable takes both its pins and the flow, and undo restores the order', () => {
const { fixture, behavior, node, edges } = interleaved();
fixture.stack.execute(new RemovePortCommand(behavior.id, node.id, 'count'));
expect(edgeCount(behavior)).toBe(0);
fixture.stack.undo();
expect(behavior.graph.edges).toEqual(edges);
});
it('on is undone to off, not to whatever the opposite of the request is', () => {
const { fixture, behavior, edit } = setup();
const node = edit.variables(['count'], { write: true });
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', true));
fixture.stack.undo();
expect(node.ports?.[0]?.write).toBe(true);
});
});
describe('Variables node titles', () => {
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
resolveDef(node, behavior.graph, () => null)!.label;
it('name a single variable, and stay plain for none or several', () => {
const { behavior, edit } = setup();
edit.addNode('GetVariable', { variable: 'count' });
edit.addNode('SetVariable', { variable: 'count' });
edit.addNode('Variable', { variable: 'count' });
edit.addNode('GetVariable');
edit.addNode('Variable');
expect([0, 1, 2, 3, 4].map((i) => titleOf(behavior, i))).toEqual([
'Get count',
'Set count',
const one = edit.variables(['count'], { write: true });
const none = edit.variables([]);
const two = edit.variables(['count', 'copy']);
expect([one, none, two].map((node) => titleOf(behavior, node))).toEqual([
'count',
'Get variable',
'Variable',
'Variables',
'Variables',
]);
});
it('follow a renamed variable', () => {
const { fixture, behavior, edit } = setup();
edit.addNode('Variable', { variable: 'count' });
const node = edit.variables(['count']);
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score'));
expect(titleOf(behavior, 0)).toBe('score');
expect(titleOf(behavior, node)).toBe('score');
});
});