import { describe, expect, it } from 'vitest'; import { CollapseNodesCommand } from '../../src/core/commands/graphCommands'; import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands'; import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; function setup(variables = ['count', 'copy']) { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B', variables); return { fixture, behavior, edit: graphEditor(fixture, behavior) }; } function run( behavior: BehaviorAsset, fixture: ReturnType, variables: Record = { count: 5, copy: 0 }, ) { const ctx = fakeContext({ variables: { ...variables } }); new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); return ctx.variables; } const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') => resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => p.key); 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 read = edit.variables(['count']); const store = edit.variables(['copy'], { write: true }); edit.wire([ [start, 'exec', store, 'exec'], [read, 'count', store, 'copy'], ]); expect(run(behavior, fixture)).toEqual({ count: 5, copy: 5 }); }); 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 number = edit.addNode('Number', { value: 3 }); const node = edit.variables(['count', 'copy', 'kept'], { write: ['count', 'copy'], params: { count: 7 }, }); edit.wire([ [start, 'exec', node, 'exec'], [number, 'value', node, 'copy'], ]); 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 count = edit.variables(['count'], { write: true }); const add = edit.addNode('Add', { b: 1 }); edit.wire([ [start, 'exec', count, 'exec'], [count, 'count', add, 'a'], [add, 'result', count, 'count'], ]); expect(run(behavior, fixture).count).toBe(6); }); 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 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', count, 'exec'], [count, 'exec', overwrite, 'exec'], [overwrite, 'exec', copy, 'exec'], [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, NO_LOOKUP)! .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('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, NO_LOOKUP)!.label; it('follow a renamed variable once collapsed', () => { const { fixture, behavior, edit } = setup(); const node = edit.variables(['count']); fixture.stack.execute(new CollapseNodesCommand(behavior.id, [node.id], true)); fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score')); expect(titleOf(behavior, node)).toBe('Get score'); }); it('say what the node does once it is collapsed, and only then', () => { const { fixture, behavior, edit } = setup(['a', 'b', 'c']); const nodes = [ edit.variables(['a', 'b', 'c'], { write: ['a', 'b'] }), edit.variables(['a', 'b', 'c'], { write: ['a'] }), edit.variables(['a', 'b'], { write: ['a'] }), edit.variables(['a'], { write: true }), edit.variables(['a']), edit.variables([]), ]; const open = nodes.map((node) => titleOf(behavior, node)); const ids = nodes.map((node) => node.id); fixture.stack.execute(new CollapseNodesCommand(behavior.id, ids, true)); expect(nodes.map((node) => titleOf(behavior, node))).toEqual([ 'Set 2 variables', 'Get 2 variables', 'Set a', 'Set a', 'Get a', 'No variables', ]); fixture.stack.undo(); expect(nodes.map((node) => titleOf(behavior, node))).toEqual(open); expect(new Set(open)).toEqual(new Set(['Variables'])); }); });