/** * Get/Set variables: the two nodes whose declared ports are variables of the behavior around them, * rather than names of their 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. */ import { describe, expect, it } from 'vitest'; import { AddPortCommand, SetVariablePortCommand } from '../../src/core/commands/portCommands'; import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand, } from '../../src/core/commands/variableCommands'; import { GET_VARIABLES, type GraphNode, GraphInterpreter, resolveDef, SET_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.addNode(SET_VARIABLES, { px: 7, py: 9 }); const get = edit.addNode(GET_VARIABLES); const add = edit.addNode('Add'); const store = edit.addNode('SetVariable', { variable: 'sum' }); for (const node of [set, get]) for (const key of ['px', 'py']) fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' })); edit.wire([ [start, 'exec', set, 'exec'], [set, 'exec', store, 'exec'], [get, 'px', add, 'a'], [get, 'py', add, 'b'], [add, 'result', store, 'value'], ]); return { fixture, behavior, set, get, edit }; } function run( behavior: BehaviorAsset, fixture: ReturnType, variables: Record = { 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']); // The exec port is the node's own; only the variables come from the declaration. expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']); }); it('follows a renamed variable, edge and all', () => { const { fixture, behavior, get } = bothWays(); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column')); expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']); expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true); expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 }); 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 SetVariablePortCommand(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 === SET_VARIABLES)!; expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']); expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 }); }); });