Files
feuerwerk/tests/core/struct-shape-ports.test.ts
T
rootandClaude Fable 5.1 e7d82bd1a9 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>
2026-09-12 18:00:23 +02:00

143 lines
5.8 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.
/**
* 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([]);
});
});