Files
feuerwerk/tests/core/state-machine.test.ts
T
adminandClaude Opus 5 7babf88636
game-maker / suite (push) Successful in 10m58s
A state change is something the graph can react to, so the machine announces it (#53, #54, #55, #57)
Four issues that all reach for the same line: how the current state is
read and written. Splitting them into four commits would mean four
states of the tree that do not compile, so they arrive together.

#55 start state — the machine asset carries an optional `start` holding
a State node's id. Right-click a state to set or remove it, a ring in
the node's title says which one it is. A dead id (the state was deleted)
reads as "no start", so nothing has to clean up after a deletion.

#53 entered/exited — `On state entered` and `On state exited`, one exec
output per state. Every change now goes through a single `setState`
that reports it; BehaviorSystem delivers it after the exec flow that
caused it, `exited` of the old before `entered` of the new, round after
round until the queue is empty, with a cap that stops a ping-pong graph
with an endless-loop error. A local machine tells the instance that
switched, a global one tells every instance holding a matching event
node. The start state fires `entered` too: per instance for a local
machine, once per run for a global one.

#57 reading — `Current state` with `name` for showing and `id` for
comparing, `Is in state` with one boolean per state. Neither has a
`run`, so `read` learned `def` and `lookup`, and the catalog test knows
this third kind of node.

#54 seeing it — during a run the state machine tab marks every state
some instance is holding faintly, and the one held by the selected
scene object's instance strongly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:18:20 +02:00

664 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
SetMachineScopeCommand,
SetMachineStartCommand,
} 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,
startStateOf,
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('the state a machine starts in', () => {
it('is kept on the asset, can be taken off again, and survives a save', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
expect(machine.asset().start).toBe(open);
expect(findAssetOf(roundTrip(f.project), machine.id, 'statemachine')?.start).toBe(open);
// The same entry takes the mark off: only one state can be the start, so there is one field.
f.stack.execute(new SetMachineStartCommand(machine.id, ''));
expect(machine.asset().start).toBeUndefined();
f.stack.undo();
expect(machine.asset().start).toBe(open);
});
it('reads as no start once that state is gone', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
f.stack.execute(new RemoveNodeCommand(machine.id, open));
// The dead id stays in the document; what the graph agrees to is what counts.
expect(machine.asset().start).toBe(open);
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe('');
expect(graphLookup(f.project).machine(machine.id)?.start).toBe('');
f.stack.undo();
expect(startStateOf(machine.asset().graph, machine.asset().start)).toBe(open);
});
it('puts every instance in it before anything reads the machine', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
const behavior = makeBehavior(f, 'Door');
const edit = graphEditor(f, behavior);
const update = edit.addNode('OnUpdate');
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
const logOpen = edit.addNode('Log', { message: 'open' });
const logClosed = edit.addNode('Log', { message: 'closed' });
edit.wire([
[update, 'exec', branch, 'exec'],
[branch, open, logOpen, 'exec'],
[branch, closed, logClosed, 'exec'],
]);
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
// Without a start state this branch would follow nothing at all (that is the old behaviour).
expect(started.world.log).toEqual(['open']);
});
});
describe('On state entered and On state exited', () => {
/** A behavior that logs every entry and exit of the machine, one Log per state and direction. */
function watcher(f: ProjectFixture, machineId: string, ids: readonly string[], name = 'Watcher') {
const behavior = makeBehavior(f, name);
const edit = graphEditor(f, behavior);
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machineId });
const exited = edit.addNode('OnStateExited', { [MACHINE_PORT]: machineId });
for (const [index, state] of ids.entries()) {
const label = index === 0 ? 'open' : 'closed';
const inLog = edit.addNode('Log', { message: `${name}: entered ${label}` });
const outLog = edit.addNode('Log', { message: `${name}: exited ${label}` });
edit.wire([
[entered, state, inLog, 'exec'],
[exited, state, outLog, 'exec'],
]);
}
return { behavior, edit };
}
it('fires for the start state too, with nothing to exit before it', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
f.stack.execute(new SetMachineStartCommand(machine.id, machine.ids[0]!));
const { behavior } = watcher(f, machine.id, machine.ids);
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
expect(started.world.log).toEqual(['Watcher: entered open']);
// Once, not once per frame: the instance is already in that state the second time round.
started.frame();
expect(started.world.log).toEqual(['Watcher: entered open']);
});
it('fires exited before entered, after the flow that moved the machine', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
const { behavior, edit } = watcher(f, machine.id, machine.ids);
// The pulse that moves the machine logs after the move: its own line comes first, the events after.
const key = edit.addNode('OnUpdate');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const done = edit.addNode('Log', { message: 'flow finished' });
edit.wire([
[key, 'exec', set, closed],
[set, 'exec', done, 'exec'],
]);
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
expect(started.world.log).toEqual([
'Watcher: entered open',
'flow finished',
'Watcher: exited open',
'Watcher: entered closed',
]);
});
it('says nothing when a set puts the machine into the state it is already in', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
const { behavior, edit } = watcher(f, machine.id, machine.ids);
const update = edit.addNode('OnUpdate');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.wire([[update, 'exec', set, open]]);
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
started.frame();
expect(started.world.log).toEqual(['Watcher: entered open']);
});
it('reaches only the instance that moved while the machine is local', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
const { behavior, edit } = watcher(f, machine.id, machine.ids);
// Both instances move themselves; what is asserted is that neither hears the other's move.
const start = edit.addNode('OnStart');
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.wire([[start, 'exec', set, closed]]);
place(f, behavior.id, { x: 0, y: 0 });
place(f, behavior.id, { x: 10, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
// Each instance hears its own move and nobody else's: two entries, no exits (no state before).
expect(started.world.log).toEqual(['Watcher: entered closed', 'Watcher: entered closed']);
expect(started.world.log.filter((line) => line.includes('exited'))).toEqual([]);
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
expect(open).not.toBe('');
});
it('reaches every instance when the machine is global, and enters its start once', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
f.stack.execute(new SetMachineScopeCommand(machine.id, 'global'));
f.stack.execute(new SetMachineStartCommand(machine.id, open));
const { behavior } = watcher(f, machine.id, machine.ids);
const mover = watcher(f, machine.id, machine.ids, 'Mover');
const start = mover.edit.addNode('OnStart');
const set = mover.edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
mover.edit.wire([[start, 'exec', set, closed]]);
place(f, behavior.id, { x: 0, y: 0 });
place(f, mover.behavior.id, { x: 10, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
// The start is entered once for the whole run and heard by both; so is the move one of them made.
expect(started.world.log).toEqual([
'Watcher: entered open',
'Mover: entered open',
'Watcher: exited open',
'Mover: exited open',
'Watcher: entered closed',
'Mover: entered closed',
]);
});
it('stops a run whose state events keep moving the machine', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
const behavior = makeBehavior(f, 'Flapper');
const edit = graphEditor(f, behavior);
const entered = edit.addNode('OnStateEntered', { [MACHINE_PORT]: machine.id });
const toClosed = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const toOpen = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.wire([
[entered, open, toClosed, closed],
[entered, closed, toOpen, open],
]);
place(f, behavior.id, { x: 0, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
expect(started.world.stopRequested).toBe(true);
expect(started.world.log.some((line) => line.includes('endless loop'))).toBe(true);
});
});
describe('reading the current state', () => {
it('hands out the name to show and the id to compare', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
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 current = edit.addNode('CurrentState', { [MACHINE_PORT]: machine.id });
const log = edit.addNode('Log');
edit.wire([
[start, 'exec', set, open],
[set, 'exec', log, 'exec'],
[current, 'name', log, 'message'],
]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['Open']);
});
it('is one boolean per state, false everywhere while the machine is in none', () => {
const f = makeProject();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
const behavior = makeBehavior(f, 'B');
const edit = graphEditor(f, behavior);
const isIn = edit.addNode('IsInState', { [MACHINE_PORT]: machine.id });
const node = behavior.graph.nodes.find((n) => n.id === isIn.id)!;
const def = resolveDef(node, behavior.graph, graphLookup(f.project))!;
expect(def.outputs.map((p) => `${p.key}:${p.type}`)).toEqual([`${open}:boolean`, `${closed}:boolean`]);
const ctx = fakeContext({ states: { [machine.id]: closed } });
expect(def.read!(ctx, {}, () => machine.id, def, graphLookup(f.project))).toEqual({
[open]: false,
[closed]: true,
});
});
});
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: [],
start: '',
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']);
});
});