State machines: asset, tab, State branch and Set state nodes (#51)

A state machine is an asset whose graph holds one `State` node per state
(no ports, name as title), edited in a new "State machine" tab that reuses
the graph canvas: right-click places "State n", snap toggle shared with the
Behavior tab via SnapToggle. `statemachine` is a value kind like texture
(port, literal node, variable, struct field, tagged list item).

Behaviors use it through `State branch` (one exec output per state) and
`Set state` (one exec input per state; the interpreter now hands `run` the
entry port). States live per behavior instance in NodeContext.states, with
no initial state. Ports derive from the inline picker or a wired
`State machine` literal; BehaviorLookup became GraphLookup with
behavior(id) and machine(id).

The Graph tab is labelled "Behavior" (key unchanged). Unit tests
(state-machine, kinds, catalog, ports), e2e step 26 and README added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 18:00:23 +02:00
1 parent ce42d16657
commit e7d82bd1a9
60 files changed
+1269 -213

No files matched your search

+8 -8
View File
@@ -15,7 +15,7 @@ import {
terminalNode,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { graphLookup } from '../../src/core/model/graphLookup';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
@@ -55,7 +55,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
}
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
return { ctx, interpreter };
@@ -73,7 +73,7 @@ describe('a behavior used as a node', () => {
const callee = doubler(fixture);
const caller = callerOf(fixture, callee, 1);
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
const def = resolveDef(call, caller.graph, behaviorLookup(fixture.project))!;
const def = resolveDef(call, caller.graph, graphLookup(fixture.project))!;
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
@@ -185,7 +185,7 @@ describe('a behavior used as a node', () => {
describe('a call that cannot do anything says so', () => {
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
const fixture = makeProject();
const lookup = () => behaviorLookup(fixture.project);
const lookup = () => graphLookup(fixture.project);
const callee = doubler(fixture);
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
@@ -214,11 +214,11 @@ describe('a call that cannot do anything says so', () => {
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
const caller = makeBehavior(fixture, 'Caller');
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
const signature = () => portSignature(call, caller.graph, behaviorLookup(fixture.project), caller.id);
const signature = () => portSignature(call, caller.graph, graphLookup(fixture.project), caller.id);
const healthy = signature();
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
expect(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare');
expect(resolveDef(call, caller.graph, graphLookup(fixture.project))!.label).toBe('Bare');
expect(signature()).not.toBe(healthy);
});
@@ -239,7 +239,7 @@ describe('a call that cannot do anything says so', () => {
]);
// A missing end node is not a defect, so nothing is reported.
expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null);
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
});
@@ -293,7 +293,7 @@ describe('declared ports', () => {
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
const ctx = fakeContext({ variables: { result: 0 } });
interpreter.fire('OnStart', ctx);
expect(ctx.variables.result).toBe(12);