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>
143 lines
5.8 KiB
TypeScript
143 lines
5.8 KiB
TypeScript
/**
|
||
* Seeding the fields of a Pack/Expand from a variable that already has the shape: a struct declares
|
||
* it, a list shows it in the struct items it holds. The node keeps declaring its own ports – this
|
||
* only copies a shape that exists elsewhere in the behavior.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands';
|
||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||
import { graphEditor, makeBehavior, makeProject } from '../helpers';
|
||
|
||
const PIECE: readonly StructFieldDef[] = [
|
||
{ key: 'shape', kind: 'number' },
|
||
{ key: 'name', kind: 'text' },
|
||
{ key: 'held', kind: 'boolean' },
|
||
{ key: 'tint', kind: 'color' },
|
||
{ key: 'cells', kind: 'list' },
|
||
];
|
||
|
||
function behaviorWithPiece() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
|
||
for (const field of PIECE) fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', field));
|
||
return { ...fixture, behavior, edit: graphEditor(fixture, behavior) };
|
||
}
|
||
|
||
const varOf = (behavior: BehaviorAsset, key: string) => behavior.variables.find((v) => v.key === key)!;
|
||
|
||
describe('the shape a variable declares', () => {
|
||
it('is the fields of a struct variable and the shape of the struct items a list holds', () => {
|
||
const fixture = behaviorWithPiece();
|
||
const { behavior, stack } = fixture;
|
||
stack.execute(new AddVariableCommand(behavior.id, 'shapes', 'list'));
|
||
// No declaration to read: the shape is the one the items in there actually have.
|
||
varOf(behavior, 'shapes').default = [{ x: 0, y: 0 }];
|
||
|
||
expect(structShapeOf(varOf(behavior, 'piece'))?.map((f) => f.key)).toEqual([
|
||
'shape',
|
||
'name',
|
||
'held',
|
||
'tint',
|
||
'cells',
|
||
]);
|
||
expect(structShapeOf(varOf(behavior, 'shapes'))?.map((f) => f.key)).toEqual(['x', 'y']);
|
||
});
|
||
|
||
it('is absent for a number, and for a list with no struct item in it', () => {
|
||
const fixture = behaviorWithPiece();
|
||
const { behavior, stack } = fixture;
|
||
stack.execute(new AddVariableCommand(behavior.id, 'score', 'number'));
|
||
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
|
||
varOf(behavior, 'names').default = ['a', 'b'];
|
||
|
||
expect(structShapeOf(varOf(behavior, 'score'))).toBeUndefined();
|
||
expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined();
|
||
});
|
||
|
||
/** A field's kind is its port kind; a color port then travels as a number, since the graph has none. */
|
||
it('gives each field a port of its own kind, a color travelling as a number', () => {
|
||
const ports = missingFieldPorts({ id: 'n', type: 'PackStruct', x: 0, y: 0, params: {} }, PIECE);
|
||
expect(ports.map((p) => `${p.key}:${p.kind}`)).toEqual([
|
||
'shape:number',
|
||
'name:text',
|
||
'held:boolean',
|
||
'tint:color',
|
||
'cells:list',
|
||
]);
|
||
expect(ports.map((p) => declaredPortType(p.kind))).toEqual([
|
||
'number',
|
||
'string',
|
||
'boolean',
|
||
'number',
|
||
'list',
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('seeding a node from that shape', () => {
|
||
it('leaves a field the node already declares alone', () => {
|
||
const { behavior, stack, edit } = behaviorWithPiece();
|
||
const pack = edit.addNode('PackStruct');
|
||
stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'shape', kind: 'text' }));
|
||
|
||
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
|
||
expect(ports.map((p) => p.key)).toEqual(['name', 'held', 'tint', 'cells']);
|
||
});
|
||
|
||
it('adds every field as one undo step, and the ports become inputs of the Pack', () => {
|
||
const fixture = behaviorWithPiece();
|
||
const { behavior, stack, edit } = fixture;
|
||
const pack = edit.addNode('PackStruct');
|
||
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
|
||
stack.execute(new AddPortsCommand(behavior.id, pack.id, ports));
|
||
|
||
const inputs = resolveDef(pack, behavior.graph, graphLookup(fixture.project))!.inputs;
|
||
expect(inputs.map((p) => `${p.key}:${p.type}`)).toEqual([
|
||
'shape:number',
|
||
'name:string',
|
||
'held:boolean',
|
||
'tint:number',
|
||
'cells:list',
|
||
]);
|
||
|
||
stack.undo();
|
||
expect(pack.ports ?? []).toEqual([]);
|
||
});
|
||
|
||
/** All-or-nothing: a clash halfway down must not leave the node with the fields before it. */
|
||
it('adds nothing at all when one of the ports clashes', () => {
|
||
const { behavior, stack, edit } = behaviorWithPiece();
|
||
const pack = edit.addNode('PackStruct');
|
||
stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'held', kind: 'boolean' }));
|
||
|
||
expect(() =>
|
||
stack.execute(
|
||
new AddPortsCommand(
|
||
behavior.id,
|
||
pack.id,
|
||
PIECE.map((f) => ({ key: f.key, kind: 'number' })),
|
||
),
|
||
),
|
||
).toThrow('"held" already exists');
|
||
expect((pack.ports ?? []).map((p) => p.key)).toEqual(['held']);
|
||
});
|
||
|
||
/** A field may be called `exec`, a port may not – the one name where a shape does not fit a node. */
|
||
it('rejects a shape whose field is called exec, and says why', () => {
|
||
const { behavior, stack, edit } = behaviorWithPiece();
|
||
stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key: 'exec', kind: 'number' }));
|
||
const pack = edit.addNode('PackStruct');
|
||
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
|
||
|
||
expect(() => stack.execute(new AddPortsCommand(behavior.id, pack.id, ports))).toThrow(
|
||
'taken by the flow port',
|
||
);
|
||
expect(pack.ports ?? []).toEqual([]);
|
||
});
|
||
});
|