Files
feuerwerk/tests/core/state-machine.test.ts
T
rootandClaude Opus 5 68c92daaa6
game-maker / suite (push) Successful in 10m22s
A transition carries a name, so a state needs no visible port (#52)
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.

Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.

Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.

`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.

The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.

One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.

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

422 lines
18 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 } 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']);
});
});