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>
276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
|
|
import { RemovePortCommand, SetPortWriteCommand } from '../../src/core/commands/portCommands';
|
|
import { AddVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
|
import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph';
|
|
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
|
import { graphLookup } from '../../src/core/model/graphLookup';
|
|
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
|
|
|
|
function setup(variables = ['count', 'copy']) {
|
|
const fixture = makeProject();
|
|
const behavior = makeBehavior(fixture, 'B', variables);
|
|
return { fixture, behavior, edit: graphEditor(fixture, behavior) };
|
|
}
|
|
|
|
function run(
|
|
behavior: BehaviorAsset,
|
|
fixture: ReturnType<typeof makeProject>,
|
|
variables: Record<string, number> = { count: 5, copy: 0 },
|
|
) {
|
|
const ctx = fakeContext({ variables: { ...variables } });
|
|
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
|
return ctx.variables;
|
|
}
|
|
|
|
const ports = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
|
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => p.key);
|
|
|
|
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
|
|
|
|
describe('the Variables node', () => {
|
|
it('can be read without a flow ever reaching it', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
const start = edit.addNode('OnStart');
|
|
const read = edit.variables(['count']);
|
|
const store = edit.variables(['copy'], { write: true });
|
|
edit.wire([
|
|
[start, 'exec', store, 'exec'],
|
|
[read, 'count', store, 'copy'],
|
|
]);
|
|
expect(run(behavior, fixture)).toEqual({ count: 5, copy: 5 });
|
|
});
|
|
|
|
it('writes the variables switched to Set, typed in or through an edge, and leaves the rest', () => {
|
|
const { fixture, behavior, edit } = setup(['count', 'copy', 'kept']);
|
|
const start = edit.addNode('OnStart');
|
|
const number = edit.addNode('Number', { value: 3 });
|
|
const node = edit.variables(['count', 'copy', 'kept'], {
|
|
write: ['count', 'copy'],
|
|
params: { count: 7 },
|
|
});
|
|
edit.wire([
|
|
[start, 'exec', node, 'exec'],
|
|
[number, 'value', node, 'copy'],
|
|
]);
|
|
expect(run(behavior, fixture, { count: 5, copy: 0, kept: 4 })).toEqual({ count: 7, copy: 3, kept: 4 });
|
|
});
|
|
|
|
it('counts up through its own output: count = count + 1', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
const start = edit.addNode('OnStart');
|
|
const count = edit.variables(['count'], { write: true });
|
|
const add = edit.addNode('Add', { b: 1 });
|
|
edit.wire([
|
|
[start, 'exec', count, 'exec'],
|
|
[count, 'count', add, 'a'],
|
|
[add, 'result', count, 'count'],
|
|
]);
|
|
expect(run(behavior, fixture).count).toBe(6);
|
|
});
|
|
|
|
it('is read at the moment it is asked, so it sees a later write by another node', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
const start = edit.addNode('OnStart');
|
|
const count = edit.variables(['count'], { write: true, params: { count: 7 } });
|
|
const overwrite = edit.variables(['count'], { write: true, params: { count: 9 } });
|
|
const copy = edit.variables(['copy'], { write: true });
|
|
edit.wire([
|
|
[start, 'exec', count, 'exec'],
|
|
[count, 'exec', overwrite, 'exec'],
|
|
[overwrite, 'exec', copy, 'exec'],
|
|
[count, 'count', copy, 'copy'],
|
|
]);
|
|
expect(run(behavior, fixture)).toEqual({ count: 9, copy: 9 });
|
|
});
|
|
|
|
/** `Get variables` answered once per pulse; one node read on both sides of a write gave the old value twice. */
|
|
it('reaches across a write of one of its variables', () => {
|
|
const { fixture, behavior, edit } = setup(['px', 'before', 'after']);
|
|
const start = edit.addNode('OnStart');
|
|
const read = edit.variables(['px']);
|
|
const before = edit.variables(['before'], { write: true });
|
|
const write = edit.variables(['px'], { write: true, params: { px: 7 } });
|
|
const after = edit.variables(['after'], { write: true });
|
|
edit.wire([
|
|
[start, 'exec', before, 'exec'],
|
|
[before, 'exec', write, 'exec'],
|
|
[write, 'exec', after, 'exec'],
|
|
[read, 'px', before, 'before'],
|
|
[read, 'px', after, 'after'],
|
|
]);
|
|
expect(run(behavior, fixture, { px: 1, before: 0, after: 0 })).toEqual({ px: 7, before: 1, after: 7 });
|
|
});
|
|
|
|
it('reads every input before it writes any, so one node swaps two variables', () => {
|
|
const { fixture, behavior, edit } = setup(['a', 'b']);
|
|
const start = edit.addNode('OnStart');
|
|
const read = edit.variables(['a', 'b']);
|
|
const swap = edit.variables(['a', 'b'], { write: true });
|
|
edit.wire([
|
|
[start, 'exec', swap, 'exec'],
|
|
[read, 'b', swap, 'a'],
|
|
[read, 'a', swap, 'b'],
|
|
]);
|
|
expect(run(behavior, fixture, { a: 1, b: 2 })).toEqual({ a: 2, b: 1 });
|
|
});
|
|
|
|
it('carries the value it writes when nothing is typed, so the node can show it', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
for (const [key, kind] of [
|
|
['label', 'text'],
|
|
['on', 'boolean'],
|
|
['items', 'list'],
|
|
] as const)
|
|
fixture.stack.execute(new AddVariableCommand(behavior.id, key, kind));
|
|
const start = edit.addNode('OnStart');
|
|
const node = edit.variables(['count', 'label', 'on', 'items'], { write: true, params: { label: 'hi' } });
|
|
edit.connect(start, 'exec', node, 'exec');
|
|
const carried = resolveDef(node, behavior.graph, NO_LOOKUP)!
|
|
.inputs.filter((p) => p.type !== 'exec')
|
|
.map((p) => [p.key, inlineValue(node, p)]);
|
|
expect(Object.fromEntries(carried)).toEqual({ count: 0, label: 'hi', on: false, items: [] });
|
|
expect(run(behavior, fixture).count).toBe(0);
|
|
});
|
|
|
|
it('has flow pins only while one of its variables is set', () => {
|
|
const { behavior, edit } = setup();
|
|
const read = edit.variables(['count', 'copy']);
|
|
const mixed = edit.variables(['count', 'copy'], { write: ['copy'] });
|
|
expect([ports(behavior, read, 'inputs'), ports(behavior, read, 'outputs')]).toEqual([
|
|
[],
|
|
['count', 'copy'],
|
|
]);
|
|
expect([ports(behavior, mixed, 'inputs'), ports(behavior, mixed, 'outputs')]).toEqual([
|
|
['exec', 'copy'],
|
|
['exec', 'count', 'copy'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('switching Set on a Variables node', () => {
|
|
function wired() {
|
|
const { fixture, behavior, edit } = setup();
|
|
const start = edit.addNode('OnStart');
|
|
const number = edit.addNode('Number', { value: 3 });
|
|
const node = edit.variables(['count', 'copy'], { write: true, params: { count: 7 } });
|
|
const next = edit.variables(['copy'], { write: true });
|
|
edit.wire([
|
|
[start, 'exec', node, 'exec'],
|
|
[node, 'exec', next, 'exec'],
|
|
[number, 'value', node, 'copy'],
|
|
[node, 'count', next, 'copy'],
|
|
]);
|
|
return { fixture, behavior, node };
|
|
}
|
|
|
|
it('off takes the input with its edge and typed value, and undo brings both back', () => {
|
|
const { fixture, behavior, node } = wired();
|
|
const edges = structuredClone(behavior.graph.edges);
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
|
|
expect(ports(behavior, node, 'inputs')).toEqual(['exec', 'count']);
|
|
expect(edgeCount(behavior)).toBe(3);
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
|
|
expect(node.params.count).toBeUndefined();
|
|
|
|
fixture.stack.undo();
|
|
fixture.stack.undo();
|
|
expect(node.params.count).toBe(7);
|
|
expect(behavior.graph.edges).toEqual(edges);
|
|
});
|
|
|
|
it('off on the last one takes the flow pins and their edges along, and undo restores the order', () => {
|
|
const { fixture, behavior, node } = wired();
|
|
const edges = structuredClone(behavior.graph.edges);
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'copy', false));
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
|
|
expect(ports(behavior, node, 'inputs')).toEqual([]);
|
|
expect(ports(behavior, node, 'outputs')).toEqual(['count', 'copy']);
|
|
// Only the read of `count` is left: the flow in and out of the node went with its pins.
|
|
expect(behavior.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['count>copy']);
|
|
|
|
fixture.stack.undo();
|
|
fixture.stack.undo();
|
|
expect(behavior.graph.edges).toEqual(edges);
|
|
});
|
|
|
|
/** Edges of both pins and of the flow, interleaved in the list: undo has to put each back in its place. */
|
|
function interleaved() {
|
|
const { fixture, behavior, edit } = setup();
|
|
const start = edit.addNode('OnStart');
|
|
const number = edit.addNode('Number', { value: 3 });
|
|
const node = edit.variables(['count'], { write: true });
|
|
const log = edit.addNode('Log');
|
|
edit.wire([
|
|
[start, 'exec', node, 'exec'],
|
|
[node, 'count', log, 'message'],
|
|
[number, 'value', node, 'count'],
|
|
[node, 'exec', log, 'exec'],
|
|
]);
|
|
return { fixture, behavior, node, edges: structuredClone(behavior.graph.edges) };
|
|
}
|
|
|
|
it('off on the only one takes its edge and the flow in one step, and undo restores the order', () => {
|
|
const { fixture, behavior, node, edges } = interleaved();
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', false));
|
|
expect(behavior.graph.edges.map((e) => e.to.port)).toEqual(['message']);
|
|
fixture.stack.undo();
|
|
expect(behavior.graph.edges).toEqual(edges);
|
|
});
|
|
|
|
it('removing the last written variable takes both its pins and the flow, and undo restores the order', () => {
|
|
const { fixture, behavior, node, edges } = interleaved();
|
|
fixture.stack.execute(new RemovePortCommand(behavior.id, node.id, 'count'));
|
|
expect(edgeCount(behavior)).toBe(0);
|
|
fixture.stack.undo();
|
|
expect(behavior.graph.edges).toEqual(edges);
|
|
});
|
|
|
|
it('on is undone to off, not to whatever the opposite of the request is', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
const node = edit.variables(['count'], { write: true });
|
|
fixture.stack.execute(new SetPortWriteCommand(behavior.id, node.id, 'count', true));
|
|
fixture.stack.undo();
|
|
expect(node.ports?.[0]?.write).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Variables node titles', () => {
|
|
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
|
|
resolveDef(node, behavior.graph, NO_LOOKUP)!.label;
|
|
|
|
it('follow a renamed variable once collapsed', () => {
|
|
const { fixture, behavior, edit } = setup();
|
|
const node = edit.variables(['count']);
|
|
fixture.stack.execute(new CollapseNodesCommand(behavior.id, [node.id], true));
|
|
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'count', 'score'));
|
|
expect(titleOf(behavior, node)).toBe('Get score');
|
|
});
|
|
|
|
it('say what the node does once it is collapsed, and only then', () => {
|
|
const { fixture, behavior, edit } = setup(['a', 'b', 'c']);
|
|
const nodes = [
|
|
edit.variables(['a', 'b', 'c'], { write: ['a', 'b'] }),
|
|
edit.variables(['a', 'b', 'c'], { write: ['a'] }),
|
|
edit.variables(['a', 'b'], { write: ['a'] }),
|
|
edit.variables(['a'], { write: true }),
|
|
edit.variables(['a']),
|
|
edit.variables([]),
|
|
];
|
|
const open = nodes.map((node) => titleOf(behavior, node));
|
|
const ids = nodes.map((node) => node.id);
|
|
fixture.stack.execute(new CollapseNodesCommand(behavior.id, ids, true));
|
|
expect(nodes.map((node) => titleOf(behavior, node))).toEqual([
|
|
'Set 2 variables',
|
|
'Get 2 variables',
|
|
'Set a',
|
|
'Set a',
|
|
'Get a',
|
|
'No variables',
|
|
]);
|
|
fixture.stack.undo();
|
|
expect(nodes.map((node) => titleOf(behavior, node))).toEqual(open);
|
|
expect(new Set(open)).toEqual(new Set(['Variables']));
|
|
});
|
|
});
|