/** * A state machine is an asset whose graph holds one `State` node per state. A behavior reads it * through `State branch` (one exec output per state) and moves it through `Set state` (one exec * input per state); the current state lives with the behavior instance, like its variables. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddNodeCommand, ConnectCommand, NameTransitionCommand, RemoveNodeCommand, SetNodeParamCommand, } from '../../src/core/commands/graphCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { BEHAVIOR_PARAM, BEHAVIOR_START, GraphInterpreter, MACHINE_PORT, nextStateName, resolveDef, STATE, STATE_IN, STATE_NAME_PARAM, STATE_OUT, statesOf, transitionNames, transitionsOf, transitionTo, } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import type { StateMachineAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { graphLookup } from '../../src/core/model/graphLookup'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { deserializeProject } from '../../src/core/store/serialize'; import { paletteEntries } from '../../src/editor/graph/paletteEntries'; import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place, type ProjectFixture, roundTrip, startWorld, } from '../helpers'; /** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) { const create = new CreateAssetCommand('statemachine', 'Doors'); fixture.stack.execute(create); const ids = names.map((name) => { const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name }); fixture.stack.execute(add); return add.node.id; }); return { id: create.asset.id, asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!, ids, }; } describe('a state machine asset', () => { it('is created empty, is not placeable, and takes states as nodes', () => { const f = makeProject(); const machine = makeMachine(f); expect(isPlaceableType('statemachine')).toBe(false); expect(statesOf(machine.asset().graph)).toEqual([ { id: machine.ids[0], name: 'Open' }, { id: machine.ids[1], name: 'Closed' }, ]); f.stack.undo(); expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']); }); it('names a state by its setting, and falls back to "State" for an empty name', () => { const f = makeProject(); const machine = makeMachine(f, ['Open']); f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, '')); expect(statesOf(machine.asset().graph)[0]?.name).toBe('State'); const node = machine.asset().graph.nodes[0]!; expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State'); }); it('survives a save and load, keeping only its states', () => { const f = makeProject(); const machine = makeMachine(f); const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine'); expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph)); // A node of any other type has no business in a machine and is dropped on load. const smuggled = deserializeProject({ id: 'p', assets: [ { id: 'm', type: 'statemachine', name: 'M', graph: { nodes: [ { id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } }, { id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } }, ], edges: [], }, }, ], }); const asset = findAsset(smuggled, 'm') as StateMachineAsset; expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]); }); it('keeps its State node out of every behavior palette: it is registered, not built in', () => { expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false); expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false); }); it('offers one palette entry, a State named by its number', () => { const f = makeProject(); const machine = makeMachine(f); const entries = paletteEntries(f.project, machine.asset()); expect(entries.map((e) => e.def.type)).toEqual([STATE]); expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' }); expect(nextStateName(machine.asset().graph)).toBe('State 3'); f.stack.undo(); expect(nextStateName(machine.asset().graph)).toBe('State 2'); }); it('is a kind of variable holding the asset id', () => { const f = makeProject(); const machine = makeMachine(f); const behavior = makeBehavior(f, 'B'); f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine')); findAsset(f.project, behavior.id)!.props.doors = machine.id; const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior'); expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']); expect(back?.props.doors).toBe(machine.id); }); }); describe('the state ports of State branch and Set state', () => { const setup = () => { const f = makeProject(); const machine = makeMachine(f); const behavior = makeBehavior(f, 'B'); const edit = graphEditor(f, behavior); const ports = (node: { id: string }, side: 'inputs' | 'outputs') => resolveDef( behavior.graph.nodes.find((n) => n.id === node.id)!, behavior.graph, graphLookup(f.project), )! [side].filter((p) => p.type === 'exec') .map((p) => `${p.key}:${p.label}`); return { f, machine, behavior, edit, ports }; }; it('are one exec port per state, keyed by the state id and labelled by its name', () => { const { machine, edit, ports } = setup(); const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id }); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]; expect(ports(branch, 'outputs')).toEqual(expected); expect(ports(set, 'inputs')).toEqual(expected); }); it('follow a State machine literal wired into the machine input', () => { const { machine, edit, ports } = setup(); const literal = edit.addNode('StateMachine', { value: machine.id }); const branch = edit.addNode('StateBranch'); expect(ports(branch, 'outputs')).toEqual([]); edit.connect(literal, 'value', branch, MACHINE_PORT); expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]); }); it('cannot follow a variable: a port is derived from the graph, not from a running value', () => { const { f, machine, behavior, edit, ports } = setup(); f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine')); findAsset(f.project, behavior.id)!.props.doors = machine.id; const variables = edit.variables(['doors']); const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id }); edit.connect(variables, 'doors', branch, MACHINE_PORT); expect(ports(branch, 'outputs')).toEqual([]); // Only the State machine literal is followed – a text holding the very same id is not. const text = edit.addNode('String', { value: machine.id }); edit.connect(text, 'value', branch, MACHINE_PORT); expect(ports(branch, 'outputs')).toEqual([]); }); it('follow a rename, and lose the edges of a state that is deleted', () => { const { f, machine, behavior, edit, ports } = setup(); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); edit.connect(start, 'exec', set, machine.ids[0]!); f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar')); expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`); expect(behavior.graph.edges).toHaveLength(1); // Picking another machine orphans the edge, as any param that decides the ports does. f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, '')); expect(behavior.graph.edges).toHaveLength(0); f.stack.undo(); expect(behavior.graph.edges).toHaveLength(1); }); }); describe('a running state machine', () => { /** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */ function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) { const behavior = makeBehavior(f, 'Door'); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId }); const update = edit.addNode('OnUpdate'); const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId }); const logOpen = edit.addNode('Log', { message: 'open' }); const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId }); const logClosed = edit.addNode('Log', { message: 'closed' }); edit.wire([ [start, 'exec', setOpen, open!], [update, 'exec', branch, 'exec'], [branch, open!, logOpen, 'exec'], [logOpen, 'exec', setClosed, closed!], [branch, closed!, logClosed, 'exec'], ]); return behavior; } it('is in no state until Set state ran, then follows the state it was put in', () => { const f = makeProject(); const machine = makeMachine(f); const behavior = build(f, machine.id, machine.ids); const ctx = fakeContext(); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); interpreter.fire('OnUpdate', ctx); expect(ctx.logs).toEqual([]); interpreter.fire('OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); interpreter.fire('OnUpdate', ctx); interpreter.fire('OnUpdate', ctx); expect(ctx.logs).toEqual(['open', 'closed']); expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); }); it('keeps one state per behavior instance', () => { const f = makeSpriteScene(); const machine = makeMachine(f); const behavior = build(f, machine.id, machine.ids); place(f, behavior.id, { x: 0, y: 0 }); place(f, behavior.id, { x: 10, y: 0 }); const started = startWorld(f, { behaviors: true }); started.frame(); // Shared state would let the second instance find the door the first one just closed. expect(started.world.log).toEqual(['open', 'open']); started.frame(); expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']); }); it('starts a called behavior in no state, whatever the caller set', () => { const f = makeProject(); const machine = makeMachine(f); const inner = makeBehavior(f, 'Inner'); const innerEdit = graphEditor(f, inner); const innerStart = innerEdit.addNode(BEHAVIOR_START); const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id }); const log = innerEdit.addNode('Log', { message: 'inner saw open' }); innerEdit.wire([ [innerStart, 'exec', branch, 'exec'], [branch, machine.ids[0]!, log, 'exec'], ]); const outer = makeBehavior(f, 'Outer'); const edit = graphEditor(f, outer); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id }); edit.wire([ [start, 'exec', set, machine.ids[0]!], [set, 'exec', call, 'exec'], ]); const ctx = fakeContext(); new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); expect(ctx.logs).toEqual([]); }); it('refuses a connection to a state port the machine does not have', () => { const f = makeProject(); const machine = makeMachine(f); const behavior = makeBehavior(f, 'B'); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); expect(() => f.stack.execute( new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }), ), ).toThrow('Port not found'); }); }); describe('transitions between states', () => { /** Draws a transition the way the canvas does: out of one state's `out`, into the other's `in`. */ const connect = (f: ProjectFixture, machineId: string, from: string, to: string) => f.stack.execute( new ConnectCommand(machineId, { node: from, port: STATE_OUT }, { node: to, port: STATE_IN }), ); it('hangs on the two exec ports every state carries', () => { const f = makeProject(); const machine = makeMachine(f); const node = machine.asset().graph.nodes[0]!; const def = resolveDef(node, machine.asset().graph, graphLookup(f.project))!; expect(def.inputs.map((p) => p.key)).toEqual([STATE_IN]); expect(def.outputs.map((p) => p.key)).toEqual([STATE_OUT]); // Without a port there is no anchor to draw, drag or right-click an edge on. expect(def.inputs[0]?.type).toBe('exec'); expect(def.outputs[0]?.type).toBe('exec'); }); it('is an ordinary edge, so drawing, undo and a save round trip come for free', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]); const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!; expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: '' }]); f.stack.undo(); expect(transitionsOf(machine.asset().graph)).toEqual([]); }); it('goes away with the state it hangs on, because both live in the one graph', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); f.stack.execute(new RemoveNodeCommand(machine.id, closed)); expect(transitionsOf(machine.asset().graph)).toEqual([]); f.stack.undo(); expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]); }); it('is what the lookup hands a running graph', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); expect(graphLookup(f.project).machine(machine.id)?.transitions).toEqual([ { from: open, to: closed, name: '' }, ]); }); it('moves the machine one step when a behavior runs State transition', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); const behavior = makeBehavior(f, 'B'); const edit = graphEditor(f, behavior); const start = edit.addNode('OnStart'); const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id }); const next = edit.addNode('StateTransition', { [MACHINE_PORT]: machine.id }); edit.wire([ [start, 'exec', set, open], [set, 'exec', next, 'exec'], ]); const ctx = fakeContext(); new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); expect(ctx.states).toEqual({ [machine.id]: closed }); }); it('carries the name it was given through a save, and undo takes it back', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); const edge = machine.asset().graph.edges[0]!; f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' alarm ')); // Trimmed on the way in, so a stray space never makes a second name that looks the same. expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]); const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine')!; expect(transitionsOf(loaded.graph)).toEqual([{ from: open, to: closed, name: 'alarm' }]); f.stack.undo(); expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]); }); it('clears a name back to unnamed rather than keeping an empty one', () => { const f = makeProject(); const machine = makeMachine(f); const [open, closed] = machine.ids as [string, string]; connect(f, machine.id, open, closed); const edge = machine.asset().graph.edges[0]!; f.stack.execute(new NameTransitionCommand(machine.id, edge.id, 'alarm')); f.stack.execute(new NameTransitionCommand(machine.id, edge.id, ' ')); expect(edge.label).toBeUndefined(); expect(transitionsOf(machine.asset().graph)).toEqual([{ from: open, to: closed, name: '' }]); }); it('reads the same name out of whichever state the machine is in', () => { const machine = { name: 'Guard', states: [], transitions: [ { from: 'patrolling', to: 'chasing', name: 'alarm' }, { from: 'searching', to: 'chasing', name: 'alarm' }, { from: 'chasing', to: 'searching', name: 'lost' }, ], }; expect(transitionTo(machine, 'patrolling', 'alarm')).toBe('chasing'); expect(transitionTo(machine, 'searching', 'alarm')).toBe('chasing'); expect(transitionTo(machine, 'chasing', 'alarm')).toBe(''); expect(transitionNames(machine)).toEqual(['alarm', 'lost']); }); });