/** * Further flow ports at a behavior's start and end node (#294): a call comes in by one of several * ways and leaves by the one its end node was reached through last, before going on along `exec`. */ import { describe, expect, it } from 'vitest'; import { CommandError } from '../../src/core/commands/Command'; import { AddPortCommand, RemovePortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; import { BEHAVIOR_END, BEHAVIOR_PARAM, BEHAVIOR_START, type GraphNode, GraphInterpreter, resolveDef, RUN_BEHAVIOR, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { fakeContext, fire, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; interface Gate { asset: BehaviorAsset; start: GraphNode; end: GraphNode; } /** A behavior with the ways in `open` and `shut` and the ways out `yes` and `no`, wired by the test. */ function gate(fixture: ProjectFixture, name = 'Gate'): Gate { const asset = makeBehavior(fixture, name); const edit = graphEditor(fixture, asset); const start = edit.addNode(BEHAVIOR_START); const end = edit.addNode(BEHAVIOR_END); for (const key of ['open', 'shut']) fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key, kind: 'flow' })); for (const key of ['yes', 'no']) fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key, kind: 'flow' })); return { asset, start, end }; } /** `On start → Gate`, entered by `entry`, with a Log on each of its outputs saying which one fired. */ function caller(fixture: ProjectFixture, callee: BehaviorAsset, entry = 'exec'): BehaviorAsset { const asset = makeBehavior(fixture, 'Caller'); const edit = graphEditor(fixture, asset); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id }); edit.connect(edit.addNode('OnStart'), 'exec', call, entry); for (const port of ['yes', 'no', 'exec']) edit.connect(call, port, edit.addNode('Log', { message: port }), 'exec'); return asset; } function logsOf(fixture: ProjectFixture, asset: BehaviorAsset): string[] { const ctx = fakeContext(); fire(new GraphInterpreter(asset.graph, graphLookup(fixture.project)), 'OnStart', ctx); return ctx.logs; } describe('flow ports declared at a start and an end node', () => { it('stand on both nodes and on the node running the behavior, flow first and facing the other way', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' })); fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'late', kind: 'flow' })); const lookup = graphLookup(fixture.project); const ports = (node: GraphNode, graph = asset.graph) => { const def = resolveDef(node, graph, lookup)!; const list = (side: typeof def.inputs) => side.map((p) => `${p.key}:${p.type}`); return { inputs: list(def.inputs), outputs: list(def.outputs) }; }; expect(ports(start).outputs).toEqual([ 'exec:exec', 'open:exec', 'shut:exec', 'late:exec', 'amount:number', ]); expect(ports(end).inputs).toEqual(['exec:exec', 'yes:exec', 'no:exec']); const outer = caller(fixture, asset); const call = outer.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; expect(ports(call, outer.graph)).toEqual({ inputs: ['exec:exec', 'open:exec', 'shut:exec', 'late:exec', 'amount:number'], outputs: ['exec:exec', 'yes:exec', 'no:exec'], }); }); it('offer nothing to type in: a flow port has no value', () => { const fixture = makeProject(); const { asset } = gate(fixture); const outer = caller(fixture, asset); const call = outer.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!; const def = resolveDef(call, outer.graph, graphLookup(fixture.project))!; expect(def.inputs.filter((p) => p.field !== undefined)).toEqual([]); }); it('share their names with the value ports, and never take `exec`', () => { const fixture = makeProject(); const { asset, end } = gate(fixture); const add = (key: string) => fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key, kind: 'number' })); expect(() => add('yes')).toThrow(CommandError); expect(() => fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'exec', kind: 'flow' })), ).toThrow(CommandError); }); it('belong to a start or an end node only', () => { const fixture = makeProject(); const asset = makeBehavior(fixture, 'Packs'); const pack = graphEditor(fixture, asset).addNode('PackStruct'); expect(() => fixture.stack.execute(new AddPortCommand(asset.id, pack.id, { key: 'way', kind: 'flow' })), ).toThrow('Only a start or an end node declares a flow port'); }); it('survive saving and loading as flow ports', () => { const fixture = makeProject(); const { asset } = gate(fixture); const loaded = deserializeProject(JSON.parse(JSON.stringify(serializeProject(fixture.project)))); const again = loaded.assets.find((a) => a.id === asset.id) as BehaviorAsset; const kinds = (type: string) => again.graph.nodes.find((n) => n.type === type)?.ports?.map((p) => `${p.key}:${p.kind}`); expect(kinds(BEHAVIOR_START)).toEqual(['open:flow', 'shut:flow']); expect(kinds(BEHAVIOR_END)).toEqual(['yes:flow', 'no:flow']); }); }); describe('a call leaving through a flow port of the end node', () => { it('fires that output first and `exec` after it', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); graphEditor(fixture, asset).connect(start, 'exec', end, 'yes'); expect(logsOf(fixture, caller(fixture, asset))).toEqual(['yes', 'exec']); }); it('fires only `exec` when the end was reached by its plain input', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); graphEditor(fixture, asset).connect(start, 'exec', end, 'exec'); expect(logsOf(fixture, caller(fixture, asset))).toEqual(['exec']); }); it('fires only `exec` when the end was never reached', () => { const fixture = makeProject(); const { asset } = gate(fixture); expect(logsOf(fixture, caller(fixture, asset))).toEqual(['exec']); }); it('takes the way reached last when one call reaches the end by several', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); const edit = graphEditor(fixture, asset); const both = edit.addNode('Sequence'); edit.wire([ [start, 'exec', both, 'exec'], [both, 'a', end, 'yes'], [both, 'b', end, 'no'], ]); expect(logsOf(fixture, caller(fixture, asset))).toEqual(['no', 'exec']); }); it('is back at `exec` alone when the plain input was reached after a named one', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); const edit = graphEditor(fixture, asset); const both = edit.addNode('Sequence'); edit.wire([ [start, 'exec', both, 'exec'], [both, 'a', end, 'yes'], [both, 'b', end, 'exec'], ]); expect(logsOf(fixture, caller(fixture, asset))).toEqual(['exec']); }); it('decides anew for every call, so a loop takes each round its own way', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'round', kind: 'number' })); const edit = graphEditor(fixture, asset); const odd = edit.addNode('Compare', { op: '==', b: 1 }); const branch = edit.addNode('Branch'); edit.wire([ [start, 'exec', branch, 'exec'], [start, 'round', odd, 'a'], [odd, 'result', branch, 'condition'], [branch, 'then', end, 'yes'], ]); const outer = makeBehavior(fixture, 'Caller'); const oEdit = graphEditor(fixture, outer); const repeat = oEdit.addNode('Repeat', { times: 3 }); const call = oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); oEdit.wire([ [oEdit.addNode('OnStart'), 'exec', repeat, 'exec'], [repeat, 'body', call, 'exec'], [repeat, 'index', call, 'round'], [call, 'yes', oEdit.addNode('Log', { message: 'yes' }), 'exec'], [call, 'exec', oEdit.addNode('Log', { message: 'exec' }), 'exec'], ]); expect(logsOf(fixture, outer)).toEqual(['exec', 'yes', 'exec', 'exec']); }); it('keeps the way an inner call took to that call', () => { const fixture = makeProject(); const inner = gate(fixture, 'Inner'); graphEditor(fixture, inner.asset).connect(inner.start, 'exec', inner.end, 'yes'); const middle = gate(fixture, 'Middle'); const mEdit = graphEditor(fixture, middle.asset); const call = mEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.asset.id }); mEdit.wire([ [middle.start, 'exec', call, 'exec'], [call, 'exec', middle.end, 'exec'], ]); expect(logsOf(fixture, caller(fixture, middle.asset))).toEqual(['exec']); }); it('hands the values of the end node out whichever way was taken', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); fixture.stack.execute(new AddPortCommand(asset.id, end.id, { key: 'score', kind: 'number' })); const edit = graphEditor(fixture, asset); edit.connect(start, 'exec', end, 'no'); edit.connect(edit.addNode('Number', { value: 7 }), 'value', end, 'score'); const outer = makeBehavior(fixture, 'Caller'); const oEdit = graphEditor(fixture, outer); const call = oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); const log = oEdit.addNode('Log'); oEdit.wire([ [oEdit.addNode('OnStart'), 'exec', call, 'exec'], [call, 'no', log, 'exec'], [call, 'score', log, 'message'], ]); expect(logsOf(fixture, outer)).toEqual(['7']); }); }); describe('a call coming in by a flow port of the start node', () => { function ways(fixture: ProjectFixture): Gate { const g = gate(fixture); const edit = graphEditor(fixture, g.asset); for (const port of ['exec', 'open', 'shut']) edit.connect(g.start, port, edit.addNode('Log', { message: `in ${port}` }), 'exec'); return g; } it('begins at the output of that name and nowhere else', () => { const fixture = makeProject(); expect(logsOf(fixture, caller(fixture, ways(fixture).asset, 'open'))).toEqual(['in open', 'exec']); }); it('begins at the plain output when it came in by `exec`', () => { const fixture = makeProject(); expect(logsOf(fixture, caller(fixture, ways(fixture).asset, 'exec'))).toEqual(['in exec', 'exec']); }); it('passes the same values in whichever way it came', () => { const fixture = makeProject(); const { asset, start } = gate(fixture); fixture.stack.execute(new AddPortCommand(asset.id, start.id, { key: 'amount', kind: 'number' })); const edit = graphEditor(fixture, asset); const log = edit.addNode('Log'); edit.connect(start, 'shut', log, 'exec'); edit.connect(start, 'amount', log, 'message'); const outer = makeBehavior(fixture, 'Caller'); const oEdit = graphEditor(fixture, outer); const call = oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id, amount: 5 }); oEdit.connect(oEdit.addNode('OnStart'), 'exec', call, 'shut'); expect(logsOf(fixture, outer)).toEqual(['5']); }); it('does nothing in a behavior without a start node, which has no such ways in', () => { const fixture = makeProject(); const asset = makeBehavior(fixture, 'Plain'); const edit = graphEditor(fixture, asset); edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('Log', { message: 'ran' }), 'exec'); const outer = makeBehavior(fixture, 'Caller'); const oEdit = graphEditor(fixture, outer); const call = oEdit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: asset.id }); oEdit.connect(oEdit.addNode('OnStart'), 'exec', call, 'exec'); expect(logsOf(fixture, outer)).toEqual(['ran']); }); }); describe('renaming and removing a flow port', () => { it('takes the edges on the calling nodes along, and undo puts them back', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); graphEditor(fixture, asset).connect(start, 'open', end, 'yes'); const outer = caller(fixture, asset, 'open'); const onCall = () => outer.graph.edges .flatMap((e) => [e.from.port, e.to.port]) .filter((port) => ['open', 'enter', 'yes', 'done'].includes(port)) .sort(); expect(onCall()).toEqual(['open', 'yes']); fixture.stack.execute(new RenamePortCommand(asset.id, start.id, 'open', 'enter')); fixture.stack.execute(new RenamePortCommand(asset.id, end.id, 'yes', 'done')); expect(onCall()).toEqual(['done', 'enter']); // Still wired through: the call comes in and leaves by the renamed ports. expect(logsOf(fixture, outer)).toEqual(['yes', 'exec']); fixture.stack.undo(); fixture.stack.undo(); expect(onCall()).toEqual(['open', 'yes']); expect(logsOf(fixture, outer)).toEqual(['yes', 'exec']); }); it('drops the edges on the calling nodes with the port, and undo brings them back', () => { const fixture = makeProject(); const { asset, start, end } = gate(fixture); graphEditor(fixture, asset).connect(start, 'exec', end, 'yes'); const outer = caller(fixture, asset); const edges = outer.graph.edges.length; fixture.stack.execute(new RemovePortCommand(asset.id, end.id, 'yes')); expect(outer.graph.edges).toHaveLength(edges - 1); expect(logsOf(fixture, outer)).toEqual(['exec']); fixture.stack.undo(); expect(outer.graph.edges).toHaveLength(edges); expect(logsOf(fixture, outer)).toEqual(['yes', 'exec']); }); });