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>
160 lines
6.9 KiB
TypeScript
160 lines
6.9 KiB
TypeScript
/** Ports a node works out for itself (`NodeDef.derivePorts`): from its settings, or from its own edges. */
|
|
import { describe, expect, it } from 'vitest';
|
|
import { requireNodeDef } from '../../src/core/commands/require';
|
|
import { AddPortCommand } from '../../src/core/commands/portCommands';
|
|
import { DisconnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
|
import {
|
|
BEHAVIOR_END,
|
|
BEHAVIOR_START,
|
|
type GraphNode,
|
|
GraphInterpreter,
|
|
inlineInputs,
|
|
resolveDef,
|
|
} from '../../src/core/graph';
|
|
import { graphLookup } from '../../src/core/model/graphLookup';
|
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
|
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
|
|
|
function sequenceFixture() {
|
|
const fixture = makeProject();
|
|
const behavior = makeBehavior(fixture, 'B');
|
|
const edit = graphEditor(fixture, behavior);
|
|
return { ...fixture, behavior, edit };
|
|
}
|
|
|
|
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
|
|
resolveDef(node, behavior.graph, graphLookup(fixture.project))!.outputs.map((p) => p.key);
|
|
|
|
const edgeKeys = (behavior: BehaviorAsset) =>
|
|
behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort();
|
|
|
|
describe('a Sequence sized by what is plugged into it', () => {
|
|
it('starts with one output and grows a spare as each last one is connected', () => {
|
|
const fixture = sequenceFixture();
|
|
const { behavior, edit } = fixture;
|
|
const node = edit.addNode('Sequence');
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
|
|
|
|
edit.connect(node, 'a', edit.addNode('Log'), 'exec');
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
|
|
edit.connect(node, 'b', edit.addNode('Log'), 'exec');
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('shrinks again when the bottom edge goes, and never below the last connected one', () => {
|
|
const fixture = sequenceFixture();
|
|
const { stack, behavior, edit } = fixture;
|
|
const node = edit.addNode('Sequence');
|
|
edit.wire(['a', 'b'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
|
|
|
|
stack.undo();
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
|
|
stack.undo();
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
|
|
});
|
|
|
|
it('keeps a gap rather than dropping an edge: the last connected port sets the size', () => {
|
|
const fixture = sequenceFixture();
|
|
const { behavior, edit } = fixture;
|
|
const node = edit.addNode('Sequence');
|
|
edit.wire(['a', 'b', 'c'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
|
|
const middle = behavior.graph.edges.find((e) => e.from.port === 'b')!;
|
|
fixture.stack.execute(new DisconnectCommand(behavior.id, middle.id));
|
|
|
|
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c', 'd']);
|
|
expect(edgeKeys(behavior)).toEqual(['a→exec', 'c→exec']);
|
|
});
|
|
|
|
it('runs the outputs it has, in order', () => {
|
|
const fixture = sequenceFixture();
|
|
const { behavior, edit } = fixture;
|
|
const node = edit.addNode('Sequence');
|
|
const onStart = edit.addNode('OnStart');
|
|
edit.connect(onStart, 'exec', node, 'exec');
|
|
for (const port of ['a', 'b'])
|
|
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
|
|
|
|
const ctx = fakeContext();
|
|
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
|
expect(ctx.logs).toEqual(['A', 'B']);
|
|
});
|
|
});
|
|
|
|
/** The point of a struct: a bundle crosses a behavior boundary on ONE edge. */
|
|
describe('a struct travelling through a nested behavior', () => {
|
|
it('goes in as one port and comes back out as its parts', () => {
|
|
const fixture = makeProject();
|
|
|
|
const callee = makeBehavior(fixture, 'Sum piece');
|
|
const inner = graphEditor(fixture, callee);
|
|
const start = inner.addNode(BEHAVIOR_START);
|
|
const end = inner.addNode(BEHAVIOR_END);
|
|
fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'piece', kind: 'struct' }));
|
|
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'total', kind: 'number' }));
|
|
const expand = inner.addNode('ExpandStruct');
|
|
for (const key of ['x', 'y'])
|
|
fixture.stack.execute(new AddPortCommand(callee.id, expand.id, { key, kind: 'number' }));
|
|
const add = inner.addNode('Add');
|
|
inner.wire([
|
|
[start, 'exec', end, 'exec'],
|
|
[start, 'piece', expand, 'value'],
|
|
[expand, 'x', add, 'a'],
|
|
[expand, 'y', add, 'b'],
|
|
[add, 'result', end, 'total'],
|
|
]);
|
|
|
|
const caller = makeBehavior(fixture, 'Caller', ['result']);
|
|
const outer = graphEditor(fixture, caller);
|
|
const pack = outer.addNode('PackStruct', { x: 30, y: 12 });
|
|
for (const key of ['x', 'y'])
|
|
fixture.stack.execute(new AddPortCommand(caller.id, pack.id, { key, kind: 'number' }));
|
|
const onStart = outer.addNode('OnStart');
|
|
const call = outer.addNode('RunBehavior', { behavior: callee.id });
|
|
const set = outer.variables(['result'], { write: true });
|
|
outer.wire([
|
|
[onStart, 'exec', call, 'exec'],
|
|
[pack, 'value', call, 'piece'],
|
|
[call, 'exec', set, 'exec'],
|
|
[call, 'total', set, 'result'],
|
|
]);
|
|
|
|
// One edge into the call, where two numbers would need two.
|
|
expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1);
|
|
const ctx = fakeContext({ variables: { result: 0 } });
|
|
new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
|
expect(ctx.variables.result).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('a struct input', () => {
|
|
it('is offered no inline field, because a bundle cannot be typed on one line', () => {
|
|
const def = requireNodeDef('ExpandStruct');
|
|
expect(def.inputs.map((p) => p.type)).toEqual(['struct']);
|
|
expect(inlineInputs(def)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('a Run behavior node whose behavior changes', () => {
|
|
it('drops the edges of the ports the new behavior does not have', () => {
|
|
const fixture = makeProject();
|
|
const called = makeBehavior(fixture, 'Called');
|
|
const other = makeBehavior(fixture, 'Other');
|
|
const start = graphEditor(fixture, called).addNode(BEHAVIOR_START);
|
|
fixture.stack.execute(new AddPortCommand(called.id, start.id, { key: 'power', kind: 'number' }));
|
|
|
|
const caller = makeBehavior(fixture, 'Caller');
|
|
const edit = graphEditor(fixture, caller);
|
|
const call = edit.addNode('RunBehavior', { behavior: called.id });
|
|
const number = edit.addNode('Number', { value: 3 });
|
|
edit.connect(number, 'value', call, 'power');
|
|
expect(edgeKeys(caller)).toEqual(['value→power']);
|
|
|
|
fixture.stack.execute(new SetNodeParamCommand(caller.id, call.id, 'behavior', other.id));
|
|
expect(edgeKeys(caller)).toEqual([]);
|
|
fixture.stack.undo();
|
|
expect(edgeKeys(caller)).toEqual(['value→power']);
|
|
expect(start.ports).toEqual([{ key: 'power', kind: 'number' }]);
|
|
});
|
|
});
|