Files
feuerwerk/tests/core/state-machine.test.ts
adminandClaude Opus 5.5 ed7872c893 Let a called behavior share the caller's local state machines
A local state machine started over in every Run behavior call, so a
state set in a sub-behavior was gone by the next call. A character
controller split into sub-behaviors could therefore never remember
that it was attacking. Scene or global scope would keep the state, but
then every character shares it.

- A call now runs on the states of the instance that runs it, however
  deep. A state one call sets is still set in the next one, and the
  call begins in the start state like the instance does.
- A move made inside a call is reported like one the caller made. The
  caller's On state entered and On state exited hear it, the event
  nodes of the called graph stay silent as before.
- Two objects running the same behavior still hold a state each.
- This reverses the rule from #229 that local machines of a called
  behavior are not kept. Keep stays a switch for variables only.

Tests:
- Two unit tests that pinned the old rule now expect the shared state.
- New world test: a sub-behavior closes a door per object, the
  caller's events hear it, and the next call finds it closed.
- Counter-check: with the old call context the 3 tests fail.
- Gate on melektaus-atx: 239 files, 3600 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:38:06 +02:00

751 lines
33 KiB
TypeScript
Raw Permalink 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,
fire,
} 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));
fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual([]);
fire(interpreter, 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
fire(interpreter, 'OnUpdate', ctx);
fire(interpreter, '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('lets a called behavior see the state its 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();
fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
// A local machine belongs to the instance, not to the graph that happens to run.
expect(ctx.logs).toEqual(['inner saw open']);
});
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));
fire(interpreter, '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.
fire(interpreter, '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('hears a move made inside a called behavior, each object in its own state', () => {
const f = makeSpriteScene();
const machine = makeMachine(f);
const [open, closed] = machine.ids as [string, string];
f.stack.execute(new SetMachineStartCommand(machine.id, open));
// Called every frame: closes the door it finds open, says so once it finds it closed.
const step = makeBehavior(f, 'Step');
const stepEdit = graphEditor(f, step);
const branch = stepEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
const set = stepEdit.addNode('SetState', { [MACHINE_PORT]: machine.id });
const said = stepEdit.addNode('Log', { message: 'Step: found closed' });
stepEdit.wire([
[stepEdit.addNode(BEHAVIOR_START), 'exec', branch, 'exec'],
[branch, open, set, closed],
[branch, closed, said, 'exec'],
]);
const { behavior, edit } = watcher(f, machine.id, machine.ids);
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: step.id });
edit.connect(edit.addNode('OnUpdate'), 'exec', call, 'exec');
place(f, behavior.id, { x: 0, y: 0 });
place(f, behavior.id, { x: 10, y: 0 });
const started = startWorld(f, { behaviors: true });
started.frame();
// The call starts in the instance's start state and its move reaches that instance's events.
expect([...started.world.log].sort()).toEqual([
'Watcher: entered closed',
'Watcher: entered closed',
'Watcher: entered open',
'Watcher: entered open',
'Watcher: exited open',
'Watcher: exited open',
]);
expect(started.behaviors?.machineStates(machine.id).map((s) => s.state)).toEqual([closed, closed]);
started.world.log.length = 0;
started.frame();
// The next call finds what the last one set, once per object.
expect(started.world.log).toEqual(['Step: found closed', 'Step: found closed']);
});
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();
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), '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();
fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), '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']);
});
});