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>
146 lines
7.1 KiB
TypeScript
146 lines
7.1 KiB
TypeScript
/**
|
||
* The Variables node declares variables of the behavior around it as ports, rather than names of
|
||
* its own. What that costs is the wiring – a port has to follow the variable it names when that is
|
||
* renamed or swapped, and take its edges along, on the input side as well as on the output side.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
|
||
import {
|
||
AddStructFieldCommand,
|
||
AddVariableCommand,
|
||
RemoveVariableCommand,
|
||
RenameVariableCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||
|
||
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
|
||
function bothWays() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.variables(['px', 'py'], { write: true, params: { px: 7, py: 9 } });
|
||
const get = edit.variables(['px', 'py']);
|
||
const add = edit.addNode('Add');
|
||
const store = edit.variables(['sum'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[set, 'exec', store, 'exec'],
|
||
[get, 'px', add, 'a'],
|
||
[get, 'py', add, 'b'],
|
||
[add, 'result', store, 'sum'],
|
||
]);
|
||
return { fixture, behavior, set, get, store, edit };
|
||
}
|
||
|
||
function run(
|
||
behavior: BehaviorAsset,
|
||
fixture: ReturnType<typeof makeProject>,
|
||
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
|
||
) {
|
||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||
const ctx = fakeContext({ variables });
|
||
interpreter.fire('OnStart', ctx);
|
||
return ctx.variables;
|
||
}
|
||
|
||
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
||
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
|
||
|
||
describe('a node that reads or writes several variables at once', () => {
|
||
it('writes each variable it names and reads each one back', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('takes the port type from the variable, so a number output stays a number', () => {
|
||
const { fixture, behavior, get, set } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
|
||
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
|
||
});
|
||
|
||
it('follows a renamed variable, edges and typed-in value on both sides', () => {
|
||
const { fixture, behavior, get, set, store } = bothWays();
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'sum', 'total'));
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
|
||
expect(set.params).toEqual({ column: 7, py: 9 });
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
|
||
expect(behavior.graph.edges.some((e) => e.to.node === store.id && e.to.port === 'total')).toBe(true);
|
||
expect(run(behavior, fixture, { column: 0, py: 0, total: 0 })).toEqual({ column: 7, py: 9, total: 16 });
|
||
|
||
fixture.stack.undo();
|
||
fixture.stack.undo();
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('keeps the edge when a port is pointed at another variable', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
|
||
fixture.stack.execute(
|
||
new RepointPortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
|
||
);
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
|
||
|
||
fixture.stack.undo();
|
||
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
|
||
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
|
||
});
|
||
|
||
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
|
||
const { fixture, behavior, get } = bothWays();
|
||
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
|
||
// Left standing rather than dropped: taking the port would have to take its edge too, and
|
||
// undo could not put either back. The inspector marks it as naming no variable.
|
||
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
|
||
|
||
fixture.stack.undo();
|
||
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
|
||
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
it('survives a save and load, which is what makes the pick more than a session', () => {
|
||
const { fixture, behavior } = bothWays();
|
||
const loaded = roundTrip(fixture.project);
|
||
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
||
const set = asset.graph.nodes.find((n) => n.type === VARIABLES && n.params.px === 7)!;
|
||
expect(set.ports).toEqual([
|
||
{ key: 'px', kind: 'number', write: true },
|
||
{ key: 'py', kind: 'number', write: true },
|
||
]);
|
||
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
|
||
});
|
||
|
||
/**
|
||
* The bundle a struct port is handed is merged in, so a `Pack` of one field leaves the rest
|
||
* standing. Tetris leans on this: each strand of `Next move` packs only the field it changes.
|
||
*/
|
||
it('merges a partial bundle into a struct variable instead of replacing it', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||
for (const key of ['shape', 'rot'])
|
||
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
|
||
const edit = graphEditor(fixture, behavior);
|
||
const start = edit.addNode('OnStart');
|
||
const set = edit.variables(['piece'], { write: true });
|
||
const pack = edit.addNode('PackStruct', { rot: 2 });
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[pack, 'value', set, 'piece'],
|
||
]);
|
||
|
||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||
});
|
||
});
|