Files
feuerwerk/tests/core/state-machine.test.ts
T
adminandClaude Opus 5 1135205d69
game-maker / suite (push) Successful in 6m12s
A machine that arrives as a value can still be commanded, so a name may name its state (#58)
Set state and State branch derived their ports from the graph, so a machine
that came out of a variable or a list had no ports at all: nothing to set,
nowhere to branch. It could be passed around but not operated.

stateByName() resolves id first, then name; a name two states carry answers
with nothing, the same rule transitionTo already follows for two transitions
of one name — without something in the graph that decides between them, any
pick would be a guess.

Set state by name takes the machine and a text, and has a second exec output
not found, so a typo is not swallowed in silence. State branch gains a
derived else that fires whenever no state output matches — no state set, a
state of another machine, or a machine that arrives as a value and therefore
has no state outputs at all. A flow that enters State branch now always
leaves it somewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:08:57 +02:00

710 lines
31 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`];
// The branch carries one more: the way out for everything no state covers.
expect(ports(branch, 'outputs')).toEqual([...expected, 'else:else']);
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(['else:else']);
edit.connect(literal, 'value', branch, MACHINE_PORT);
expect(ports(branch, 'outputs')).toEqual([
`${machine.ids[0]}:Open`,
`${machine.ids[1]}:Closed`,
'else:else',
]);
});
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 });
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
edit.connect(variables, 'doors', branch, MACHINE_PORT);
edit.connect(variables, 'doors', set, MACHINE_PORT);
// `else` is all that is left of the branch, and Set state has nothing at all – which is why
// "Set state by name" exists: it names the state instead of wiring it.
expect(ports(branch, 'outputs')).toEqual(['else:else']);
expect(ports(set, 'inputs')).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(['else:else']);
});
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('is driven by name where the machine arrives as a value, and branches through else', () => {
const f = makeProject();
const machine = makeMachine(f);
const behavior = makeBehavior(f, 'Door');
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
findAsset(f.project, behavior.id)!.props.doors = machine.id;
const edit = graphEditor(f, behavior);
const variables = edit.variables(['doors']);
const start = edit.addNode('OnStart');
const set = edit.addNode('SetStateByName', { state: 'Closed' });
const missing = edit.addNode('SetStateByName', { state: 'Ajar' });
const logMissing = edit.addNode('Log', { message: 'no such state' });
const update = edit.addNode('OnUpdate');
const branch = edit.addNode('StateBranch');
const logElse = edit.addNode('Log', { message: 'unknown to the graph' });
edit.wire([
[variables, 'doors', set, MACHINE_PORT],
[variables, 'doors', missing, MACHINE_PORT],
[variables, 'doors', branch, MACHINE_PORT],
[start, 'exec', set, 'exec'],
[set, 'exec', missing, 'exec'],
[missing, 'notFound', logMissing, 'exec'],
[update, 'exec', branch, 'exec'],
[branch, 'else', logElse, 'exec'],
]);
const ctx = fakeContext({ variables: { doors: machine.id } });
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnStart', ctx);
// The name moved the machine; the one nothing answers to took "not found" instead of ending.
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
expect(ctx.logs).toEqual(['no such state']);
// The branch has no state outputs at all here, so even a state that is set leaves through else.
interpreter.fire('OnUpdate', ctx);
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
});
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']);
});
});