/** * 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 { AddStructFieldCommand, 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 }); }); /** * The rule that decides how far a `Get variables` may reach: a data node answers once per pulse * and that answer is kept, so a single node cannot straddle a write of one of its variables – the * later reader would be served the value from before it. A second node is the way across. */ it('answers once per pulse, so a reader past a write needs a node of its own', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const early = edit.addNode(GET_VARIABLES); const late = edit.addNode(GET_VARIABLES); const write = edit.addNode(SET_VARIABLES, { px: 7 }); const before = edit.addNode('SetVariable', { variable: 'sum' }); const after = edit.addNode('SetVariable', { variable: 'py' }); const again = edit.addNode('SetVariable', { variable: 'fresh' }); for (const node of [early, late, write]) fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' })); edit.wire([ [start, 'exec', before, 'exec'], [before, 'exec', write, 'exec'], [write, 'exec', after, 'exec'], [after, 'exec', again, 'exec'], [early, 'px', before, 'value'], [early, 'px', after, 'value'], [late, 'px', again, 'value'], ]); // `py` is the point: the same node read past the write still answers 1, while `late` sees 7. expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({ px: 7, py: 1, sum: 1, fresh: 7, }); }); 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 }); }); /** * A struct port writes the way the singular node does: the bundle it 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 and lets the plural node keep the other three. */ 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.addNode(SET_VARIABLES); const pack = edit.addNode('PackStruct', { rot: 2 }); fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' })); 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 }); }); /** * The other half of how far a plural node may reach: it collects every input before it writes any * of them, so an input fed from a variable the same node writes sees the value from before. */ it('reads every input before it writes any, so a plural set cannot feed itself', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B', ['px', 'py']); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const read = edit.addNode('GetVariable', { variable: 'px' }); const set = edit.addNode(SET_VARIABLES, { px: 7 }); for (const key of ['px', 'py']) fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' })); edit.wire([ [start, 'exec', set, 'exec'], [read, 'value', set, 'py'], ]); expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 }); }); });