/** * 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, NO_LOOKUP, resolveDef, VARIABLES } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; 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, variables: Record = { px: 0, py: 0, sum: 0 }, ) { const interpreter = new GraphInterpreter(behavior.graph, graphLookup(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, NO_LOOKUP)![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, graphLookup(fixture.project)).fire('OnStart', ctx); expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 }); }); });