/** * A `statemachine` input of a behavior's start node can say which machine it carries. The state * nodes it feeds then offer that machine's states, and a node running the behavior has it picked * in advance – a different pick there is a problem on that node. A variable a Variables node reads * can say the same. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { AddPortCommand, SetPortMachineCommand } from '../../src/core/commands/portCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { BEHAVIOR_PARAM, BEHAVIOR_START, callProblem, GraphInterpreter, inlineValue, MACHINE_PORT, resolveDef, RUN_BEHAVIOR, STATE, STATE_NAME_PARAM, } from '../../src/core/graph'; import { graphLookup } from '../../src/core/model/graphLookup'; import { findAssetOf } from '../../src/core/model/Project'; import { nodeSignature } from '../../src/editor/inspector/nodeSection'; import { fakeContext, fire, graphEditor, makeBehavior, makeProject, type ProjectFixture, roundTrip, } from '../helpers'; function makeMachine(f: ProjectFixture, name: string, states: readonly string[]) { const create = new CreateAssetCommand('statemachine', name); f.stack.execute(create); const ids = states.map((state) => { const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: state }); f.stack.execute(add); return add.node.id; }); return { id: create.asset.id, ids }; } /** "Inner": its start hands `machine` to an `Is in state` and to a `Set state`. */ function build(f: ProjectFixture) { const doors = makeMachine(f, 'Doors', ['Open', 'Closed']); const lights = makeMachine(f, 'Lights', ['On']); const inner = makeBehavior(f, 'Inner'); const edit = graphEditor(f, inner); const start = edit.addNode(BEHAVIOR_START); f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'machine', kind: 'statemachine' })); const isIn = edit.addNode('IsInState'); const set = edit.addNode('SetState'); edit.wire([ [start, 'machine', isIn, MACHINE_PORT], [start, 'machine', set, MACHINE_PORT], ]); const ports = (node = isIn) => resolveDef(node, inner.graph, graphLookup(f.project))!.outputs.map((p) => p.label); return { doors, lights, inner, start, isIn, set, ports }; } describe('a machine carried by a start node input', () => { it('gives the nodes it feeds the states of that machine, and none without one', () => { const f = makeProject(); const { doors, inner, start, ports } = build(f); expect(ports()).toEqual([]); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); expect(ports()).toEqual(['Open', 'Closed']); f.stack.undo(); expect(ports()).toEqual([]); expect(start.ports).toEqual([{ key: 'machine', kind: 'statemachine' }]); }); it('survives a save and load', () => { const f = makeProject(); const { doors, inner, start } = build(f); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); const loaded = findAssetOf(roundTrip(f.project), inner.id, 'behavior')!; const port = loaded.graph.nodes.find((n) => n.id === start.id)!.ports; expect(port).toEqual([{ key: 'machine', kind: 'statemachine', machine: doors.id }]); }); it('is picked in advance on a node running the behavior, and moves that machine', () => { const f = makeProject(); const { doors, inner, start, set } = build(f); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); graphEditor(f, inner).connect(start, 'exec', set, doors.ids[1]!); const outer = makeBehavior(f, 'Outer'); const edit = graphEditor(f, outer); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }); edit.connect(edit.addNode('OnStart'), 'exec', call, 'exec'); const lookup = graphLookup(f.project); const input = resolveDef(call, outer.graph, lookup)!.inputs.find((p) => p.key === 'machine')!; expect(inlineValue(call, input)).toBe(doors.id); // Shared, so the move outlives the call and can be read here. const ctx = fakeContext({ shared: { variables: {}, states: { [doors.id]: '' } } }); fire(new GraphInterpreter(outer.graph, lookup), 'OnStart', ctx); expect(ctx.shared.states[doors.id]).toBe(doors.ids[1]); }); it('warns on a node that picks a different machine, not on one picking the same', () => { const f = makeProject(); const { doors, lights, inner, start } = build(f); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); const outer = makeBehavior(f, 'Outer'); const edit = graphEditor(f, outer); const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id }); const lookup = () => graphLookup(f.project); expect(callProblem(call, lookup(), outer.id)).toBeNull(); call.params.machine = lights.id; expect(callProblem(call, lookup(), outer.id)).toBe('"machine" expects "Doors"'); call.params.machine = doors.id; expect(callProblem(call, lookup(), outer.id)).toBeNull(); }); it('is refused on a port of another kind', () => { const f = makeProject(); const { doors, inner, start } = build(f); f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'speed', kind: 'number' })); expect(() => f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'speed', doors.id))).toThrow( '"speed" is not a state machine', ); }); it('rebuilds the inspector of the start node when it changes', () => { const f = makeProject(); const { doors, lights, inner, start } = build(f); const before = nodeSignature(f.project, inner.id, start.id); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id)); const picked = nodeSignature(f.project, inner.id, start.id); expect(picked).not.toBe(before); f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', lights.id)); expect(nodeSignature(f.project, inner.id, start.id)).not.toBe(picked); }); }); describe('a machine carried by a variable of a Variables node', () => { it('gives the nodes it feeds the states of that machine, and none without one', () => { const f = makeProject(); const doors = makeMachine(f, 'Doors', ['Open', 'Closed']); const behavior = makeBehavior(f, 'Door'); f.stack.execute(new AddVariableCommand(behavior.id, 'door', 'statemachine')); const edit = graphEditor(f, behavior); const read = edit.variables(['door']); const isIn = edit.addNode('IsInState'); edit.connect(read, 'door', isIn, MACHINE_PORT); const ports = () => resolveDef(isIn, behavior.graph, graphLookup(f.project))!.outputs.map((p) => p.label); expect(ports()).toEqual([]); f.stack.execute(new SetPortMachineCommand(behavior.id, read.id, 'door', doors.id)); expect(ports()).toEqual(['Open', 'Closed']); f.stack.undo(); expect(ports()).toEqual([]); }); });