import { describe, expect, it } from 'vitest'; import { CommandError } from '../../src/core/commands/Command'; import { AddNodeCommand, RemoveNodeCommand } from '../../src/core/commands/graphCommands'; import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, callProblem, type Graph, GraphInterpreter, portSignature, RUN_BEHAVIOR, resolveDef, terminalNode, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { behaviorLookup } from '../../src/core/model/behaviorPrograms'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; /** * A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its * start and end node. Small on purpose – what the tests are about is the call, not the arithmetic. */ function doubler(fixture: ProjectFixture, name = 'Double'): BehaviorAsset { const asset = makeBehavior(fixture, name); const edit = graphEditor(fixture, asset); const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'in1', kind: 'number' })); fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'out1', kind: 'number' })); const times = edit.addNode('Multiply', { b: 2 }); edit.wire([ [start, 'exec', end, 'exec'], [start, 'in1', times, 'a'], [times, 'result', end, 'out1'], ]); return asset; } /** A caller that runs `callee` on start with `input` and stores the result in its own `result` var. */ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number): BehaviorAsset { const asset = makeBehavior(fixture, 'Caller', ['result']); const edit = graphEditor(fixture, asset); const start = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: input }); const set = edit.variables(['result'], { write: true }); edit.wire([ [start, 'exec', call, 'exec'], [call, 'exec', set, 'exec'], [call, 'out1', set, 'result'], ]); return asset; } function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record = {}) { const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project)); const ctx = fakeContext({ variables }); interpreter.fire('OnStart', ctx); return { ctx, interpreter }; } describe('a behavior used as a node', () => { it('runs the other graph in place and hands its result back', () => { const fixture = makeProject(); const caller = callerOf(fixture, doubler(fixture), 21); expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(42); }); it('takes its ports from the other behavior, not from its own type', () => { const fixture = makeProject(); const callee = doubler(fixture); const caller = callerOf(fixture, callee, 1); const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; const def = resolveDef(call, caller.graph, behaviorLookup(fixture.project))!; expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']); expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']); // Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior". expect(def.label).toBe('Double'); }); it('keeps its variables to itself: the caller’s are untouched and nothing survives the call', () => { const fixture = makeProject(); const callee = makeBehavior(fixture, 'Counter', ['count']); const edit = graphEditor(fixture, callee); const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); const get = edit.variables(['count']); const plus = edit.addNode('Add', { b: 1 }); const set = edit.variables(['count'], { write: true }); edit.wire([ [start, 'exec', set, 'exec'], [get, 'count', plus, 'a'], [plus, 'result', set, 'count'], [set, 'exec', end, 'exec'], [plus, 'result', end, 'out1'], ]); const caller = makeBehavior(fixture, 'Caller', ['result', 'count']); const cEdit = graphEditor(fixture, caller); const onStart = cEdit.addNode('OnStart'); const first = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const second = cEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const store = cEdit.variables(['result'], { write: true }); cEdit.wire([ [onStart, 'exec', first, 'exec'], [first, 'exec', second, 'exec'], [second, 'exec', store, 'exec'], [second, 'out1', store, 'result'], ]); const { ctx } = runOnStart(fixture, caller, { result: 0, count: 500 }); // 1, not 2: the second call starts from the asset default again, not from what the first left. expect(ctx.variables.result).toBe(1); // The caller has a `count` of its own – the nested run must not have written into it. expect(ctx.variables.count).toBe(500); }); it('runs the nested graph in its own frame, so equal node ids cannot collide', () => { const fixture = makeProject(); const callee = makeBehavior(fixture, 'Ninetynine'); const cEdit = graphEditor(fixture, callee); const start = cEdit.addNode(BEHAVIOR_START); const end = cEdit.addNode(BEHAVIOR_END); fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' })); const theirs = cEdit.addNode('Number', { value: 99 }); cEdit.wire([ [start, 'exec', end, 'exec'], [theirs, 'value', end, 'out1'], ]); const caller = makeBehavior(fixture, 'Caller', ['result']); const edit = graphEditor(fixture, caller); const onStart = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const set = edit.variables(['result'], { write: true }); const mine = edit.addNode('Number', { value: 7 }); edit.wire([ [onStart, 'exec', call, 'exec'], // Read *after* the call, and from the caller's own node – the point of the test. [call, 'exec', set, 'exec'], [mine, 'value', set, 'result'], ]); // Ids are unique in practice; forcing a collision is the only way to show the frames are real. // Both nodes have a `value` output, so a shared cache would hand the caller the nested 99. const shared = 'same-id'; retarget(callee.graph, theirs.id, shared); retarget(caller.graph, mine.id, shared); expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(7); }); it('refuses to run itself rather than looping forever', () => { const fixture = makeProject(); const asset = doubler(fixture, 'Loop'); const edit = graphEditor(fixture, asset); const start = terminalNode(asset.graph, BEHAVIOR_START)!; const self = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); edit.connect(start, 'exec', self, 'exec'); const caller = callerOf(fixture, asset, 1); expect(() => runOnStart(fixture, caller, { result: 0 })).toThrow(/runs itself/); }); it('a call into nothing is quiet: no behavior chosen, or one without a start node', () => { const fixture = makeProject(); const empty = makeBehavior(fixture, 'Empty'); for (const target of ['', empty.id]) { const caller = makeBehavior(fixture, `C${target}`, ['result']); const edit = graphEditor(fixture, caller); const start = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: target }); const set = edit.variables(['result'], { write: true }); edit.wire([ [start, 'exec', call, 'exec'], // The flow carries on past the call, which is what keeps a dangling reference harmless. [call, 'exec', set, 'exec'], ]); expect(runOnStart(fixture, caller, { result: 7 }).ctx.variables.result).toBe(0); } }); }); describe('a call that cannot do anything says so', () => { it('names each of the four silent cases, and stays quiet about a healthy call', () => { const fixture = makeProject(); const lookup = () => behaviorLookup(fixture.project); const callee = doubler(fixture); const caller = makeBehavior(fixture, 'Caller'); const edit = graphEditor(fixture, caller); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: '' }); const problem = () => callProblem(call, lookup(), caller.id); expect(problem()).toBe('No behavior picked'); call.params[BEHAVIOR_PARAM] = caller.id; expect(problem()).toBe('A behavior cannot run itself'); call.params[BEHAVIOR_PARAM] = 'gone'; expect(problem()).toBe('That behavior is gone'); call.params[BEHAVIOR_PARAM] = makeBehavior(fixture, 'Empty').id; expect(problem()).toBe('No start node – does nothing'); call.params[BEHAVIOR_PARAM] = callee.id; expect(problem()).toBe(null); // Only a call can be dead this way; no other node type may be marked. expect(callProblem(edit.addNode('OnStart'), lookup(), caller.id)).toBe(null); }); it('shows up in what the node view redraws on, or it would be drawn once and never again', () => { const fixture = makeProject(); // A callee that declares no ports at all: losing its start node changes nothing about the // calling node except that it is now dead. Anything with ports would move the signature by // itself and prove nothing. const callee = makeBehavior(fixture, 'Bare'); const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START); const caller = makeBehavior(fixture, 'Caller'); const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); const signature = () => portSignature(call, caller.graph, behaviorLookup(fixture.project), caller.id); const healthy = signature(); fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id)); expect(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare'); expect(signature()).not.toBe(healthy); }); it('without an end node the call is still a complete one: it just returns nothing', () => { const fixture = makeProject(); const callee = makeBehavior(fixture, 'NoEnd'); const cEdit = graphEditor(fixture, callee); const start = cEdit.addNode(BEHAVIOR_START); cEdit.connect(start, 'exec', cEdit.addNode('Log', { message: 'sub' }), 'exec'); const caller = makeBehavior(fixture, 'Caller'); const edit = graphEditor(fixture, caller); const onStart = edit.addNode('OnStart'); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); edit.wire([ [onStart, 'exec', call, 'exec'], [call, 'exec', edit.addNode('Log', { message: 'after' }), 'exec'], ]); // A missing end node is not a defect, so nothing is reported. expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null); // "Done" is the nested flow running out: the sub graph is finished before the caller goes on. expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']); }); }); describe('declared ports', () => { it('renaming one rewrites the edges in this graph and in every calling one', () => { const fixture = makeProject(); const callee = doubler(fixture); const caller = callerOf(fixture, callee, 3); fixture.stack.execute( new RenamePortCommand(callee.id, terminalNode(callee.graph, BEHAVIOR_START)!.id, 'in1', 'factor'), ); const start = terminalNode(callee.graph, BEHAVIOR_START)!; expect(start.ports).toEqual([{ key: 'factor', kind: 'number' }]); expect(callee.graph.edges.some((e) => e.from.port === 'factor')).toBe(true); // The value typed into the calling node moves with the port; losing it would be silent. expect(caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!.params).toMatchObject({ factor: 3 }); expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(6); }); it('a node of an unknown type is refused with a CommandError, which the editor turns into a notice', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture); const ports = [{ key: 'a', kind: 'number' as const }]; behavior.graph.nodes.push({ id: 'n', type: 'NoSuchNode', x: 0, y: 0, params: {}, ports }); expect(() => new RemovePortCommand(behavior.id, 'n', 'a').execute(fixture.project)).toThrow(CommandError); }); it('removing one takes the edges hanging on it, in both graphs, and undo brings them back', () => { const fixture = makeProject(); const callee = doubler(fixture); const caller = callerOf(fixture, callee, 4); const edgesBefore = { callee: callee.graph.edges.length, caller: caller.graph.edges.length }; fixture.stack.execute( new RemovePortCommand(callee.id, terminalNode(callee.graph, BEHAVIOR_END)!.id, 'out1'), ); expect(callee.graph.edges.length).toBe(edgesBefore.callee - 1); expect(caller.graph.edges.length).toBe(edgesBefore.caller - 1); fixture.stack.undo(); expect(callee.graph.edges.length).toBe(edgesBefore.callee); expect(caller.graph.edges.length).toBe(edgesBefore.caller); expect(runOnStart(fixture, caller, { result: 0 }).ctx.variables.result).toBe(8); }); it('survives saving and loading', () => { const fixture = makeProject(); const callee = doubler(fixture); callerOf(fixture, callee, 6); const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(fixture.project)))); const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset; expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]); const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset; const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded)); const ctx = fakeContext({ variables: { result: 0 } }); interpreter.fire('OnStart', ctx); expect(ctx.variables.result).toBe(12); }); it('a behavior has one start and one end node, never two', () => { const fixture = makeProject(); const asset = doubler(fixture); for (const type of [BEHAVIOR_START, BEHAVIOR_END]) { expect(() => fixture.stack.execute(new AddNodeCommand(asset.id, type, 0, 0))).toThrow(CommandError); } }); }); /** Gives a node a different id, edges included – used to force an id collision across graphs. */ function retarget(graph: Graph, from: string, to: string): void { graph.nodes.find((n) => n.id === from)!.id = to; for (const edge of graph.edges) { if (edge.from.node === from) edge.from.node = to; if (edge.to.node === from) edge.to.node = to; } }