Files
feuerwerk/tests/core/variable-node.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

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']));
});
});