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>
This commit is contained in:
1 parent
ce42d16657
commit
e7d82bd1a9
60 files changed
+1269
-213
No files matched your search
@@ -11,7 +11,7 @@ import {
|
||||
inlineInputs,
|
||||
resolveDef,
|
||||
} from '../../src/core/graph';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
@@ -23,7 +23,7 @@ function sequenceFixture() {
|
||||
}
|
||||
|
||||
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
|
||||
resolveDef(node, behavior.graph, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
|
||||
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();
|
||||
@@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => {
|
||||
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
|
||||
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.logs).toEqual(['A', 'B']);
|
||||
});
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => {
|
||||
// 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, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables.result).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-10
@@ -19,7 +19,7 @@ import {
|
||||
dropTargetPort,
|
||||
findNode,
|
||||
firstPortFor,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
type PortType,
|
||||
portsCompatible,
|
||||
} from '../../src/core/graph';
|
||||
@@ -72,7 +72,8 @@ describe('node registry', () => {
|
||||
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||||
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
|
||||
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
||||
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
|
||||
// An event is seeded by the runtime, a state is a name in a machine: neither runs.
|
||||
if (def.category === 'event' || def.category === 'state') expect(def.inputs).toHaveLength(0);
|
||||
else expect(def.run).toBeTypeOf('function');
|
||||
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||||
}
|
||||
@@ -522,7 +523,7 @@ describe('dropping an edge on a node', () => {
|
||||
it('picks the first compatible input', () => {
|
||||
const { graph, number, clamp } = fixtureWithClamp();
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toEqual({
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toEqual({
|
||||
nodeId: clamp.id,
|
||||
port: 'value',
|
||||
isOutput: false,
|
||||
@@ -534,7 +535,7 @@ describe('dropping an edge on a node', () => {
|
||||
const other = edit.addNode('Number');
|
||||
edit.connect(other, 'value', clamp, 'value');
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('min');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('min');
|
||||
});
|
||||
|
||||
it('falls back to the last compatible input once every one is taken', () => {
|
||||
@@ -542,7 +543,7 @@ describe('dropping an edge on a node', () => {
|
||||
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)?.port).toBe('max');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('max');
|
||||
});
|
||||
|
||||
it('keeps offering an exec input, which is never exclusive', () => {
|
||||
@@ -553,26 +554,26 @@ describe('dropping an edge on a node', () => {
|
||||
const move = edit.addNode('SetPosition');
|
||||
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
|
||||
const from = { nodeId: held.id, port: 'exec', isOutput: true };
|
||||
expect(dropTargetPort(behavior.graph, from, move.id, NO_BEHAVIORS)?.port).toBe('exec');
|
||||
expect(dropTargetPort(behavior.graph, from, move.id, NO_LOOKUP)?.port).toBe('exec');
|
||||
});
|
||||
|
||||
it('refuses a node that offers nothing compatible, and the node itself', () => {
|
||||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||||
const boolean = edit.addNode('Boolean');
|
||||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||||
expect(dropTargetPort(graph, from, boolean.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, boolean.id, NO_LOOKUP)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toBeNull();
|
||||
// A pair that is already wired is not offered again either.
|
||||
edit.connect(number, 'value', clamp, 'value');
|
||||
edit.connect(number, 'value', clamp, 'min');
|
||||
edit.connect(number, 'value', clamp, 'max');
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_BEHAVIORS)).toBeNull();
|
||||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a drag started at an input to an output', () => {
|
||||
const { graph, number, clamp } = fixtureWithClamp();
|
||||
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
|
||||
expect(dropTargetPort(graph, from, number.id, NO_BEHAVIORS)).toEqual({
|
||||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toEqual({
|
||||
nodeId: number.id,
|
||||
port: 'value',
|
||||
isOutput: true,
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('the literal nodes', () => {
|
||||
['Boolean', 'boolean'],
|
||||
['String', 'text'],
|
||||
['Texture', 'texture'],
|
||||
['StateMachine', 'statemachine'],
|
||||
['List', 'list'],
|
||||
] as const) {
|
||||
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TraceListener,
|
||||
type Value,
|
||||
} from '../../src/core/graph';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
|
||||
import { Input } from '../../src/runtime/systems/Input';
|
||||
import { World } from '../../src/runtime/World';
|
||||
@@ -44,7 +44,7 @@ describe('live values', () => {
|
||||
[add, 'result', count, 'count'],
|
||||
]);
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
@@ -75,7 +75,7 @@ describe('live values', () => {
|
||||
const call = outer.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id, in1: 21 });
|
||||
outer.connect(onStart, 'exec', call, 'exec');
|
||||
const { seen, listener } = recorder();
|
||||
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
|
||||
interpreter.trace = listener;
|
||||
interpreter.fire('OnStart', fakeContext());
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
terminalNode,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
@@ -55,7 +55,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
|
||||
}
|
||||
|
||||
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
|
||||
const interpreter = new GraphInterpreter(asset.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
|
||||
const ctx = fakeContext({ variables });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
return { ctx, interpreter };
|
||||
@@ -73,7 +73,7 @@ describe('a behavior used as a node', () => {
|
||||
const callee = doubler(fixture);
|
||||
const caller = callerOf(fixture, callee, 1);
|
||||
const call = caller.graph.nodes.find((n) => n.type === RUN_BEHAVIOR)!;
|
||||
const def = resolveDef(call, caller.graph, behaviorLookup(fixture.project))!;
|
||||
const def = resolveDef(call, caller.graph, graphLookup(fixture.project))!;
|
||||
expect(def.inputs.map((p) => p.key)).toEqual(['exec', 'in1']);
|
||||
expect(def.outputs.map((p) => p.key)).toEqual(['exec', 'out1']);
|
||||
// Named after the behavior it runs: a graph of calls is unreadable as a row of "Run behavior".
|
||||
@@ -185,7 +185,7 @@ describe('a behavior used as a node', () => {
|
||||
describe('a call that cannot do anything says so', () => {
|
||||
it('names each of the four silent cases, and stays quiet about a healthy call', () => {
|
||||
const fixture = makeProject();
|
||||
const lookup = () => behaviorLookup(fixture.project);
|
||||
const lookup = () => graphLookup(fixture.project);
|
||||
const callee = doubler(fixture);
|
||||
const caller = makeBehavior(fixture, 'Caller');
|
||||
const edit = graphEditor(fixture, caller);
|
||||
@@ -214,11 +214,11 @@ describe('a call that cannot do anything says so', () => {
|
||||
const start = graphEditor(fixture, callee).addNode(BEHAVIOR_START);
|
||||
const caller = makeBehavior(fixture, 'Caller');
|
||||
const call = graphEditor(fixture, caller).addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: callee.id });
|
||||
const signature = () => portSignature(call, caller.graph, behaviorLookup(fixture.project), caller.id);
|
||||
const signature = () => portSignature(call, caller.graph, graphLookup(fixture.project), caller.id);
|
||||
|
||||
const healthy = signature();
|
||||
fixture.stack.execute(new RemoveNodeCommand(callee.id, start.id));
|
||||
expect(resolveDef(call, caller.graph, behaviorLookup(fixture.project))!.label).toBe('Bare');
|
||||
expect(resolveDef(call, caller.graph, graphLookup(fixture.project))!.label).toBe('Bare');
|
||||
expect(signature()).not.toBe(healthy);
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('a call that cannot do anything says so', () => {
|
||||
]);
|
||||
|
||||
// A missing end node is not a defect, so nothing is reported.
|
||||
expect(callProblem(call, behaviorLookup(fixture.project), caller.id)).toBe(null);
|
||||
expect(callProblem(call, graphLookup(fixture.project), caller.id)).toBe(null);
|
||||
// "Done" is the nested flow running out: the sub graph is finished before the caller goes on.
|
||||
expect(runOnStart(fixture, caller).ctx.logs).toEqual(['sub', 'after']);
|
||||
});
|
||||
@@ -293,7 +293,7 @@ describe('declared ports', () => {
|
||||
const reloaded = loaded.assets.find((a) => a.id === callee.id) as BehaviorAsset;
|
||||
expect(terminalNode(reloaded.graph, BEHAVIOR_START)?.ports).toEqual([{ key: 'in1', kind: 'number' }]);
|
||||
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
|
||||
const interpreter = new GraphInterpreter(caller.graph, behaviorLookup(loaded));
|
||||
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
|
||||
const ctx = fakeContext({ variables: { result: 0 } });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
expect(ctx.variables.result).toBe(12);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import {
|
||||
createEmptyGraph,
|
||||
type DeclaredPort,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
type NodeContext,
|
||||
resolveDef,
|
||||
type RunResult,
|
||||
@@ -29,6 +29,9 @@ interface Case {
|
||||
outputs?: Record<string, Value>;
|
||||
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
|
||||
derivedOutputs?: string[];
|
||||
/** Exec inputs grown the same way, and the one the pulse arrives through (`exec` unless said). */
|
||||
derivedInputs?: string[];
|
||||
entry?: string;
|
||||
/** Expected exec continuation; `null` asserts the flow ends here. */
|
||||
next?: string | string[] | null;
|
||||
/** Expected loop request; `null` asserts the node does not loop. */
|
||||
@@ -42,10 +45,14 @@ interface Case {
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
const node = { id: 'n', type, x: 0, y: 0, params: c.params ?? {}, ports: c.ports };
|
||||
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_BEHAVIORS)! : requireNodeDef(type);
|
||||
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
|
||||
const registered = c.ports ? resolveDef(node, createEmptyGraph(), NO_LOOKUP)! : requireNodeDef(type);
|
||||
const execPorts = (keys: string[] = []) => keys.map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = {
|
||||
...registered,
|
||||
inputs: [...execPorts(c.derivedInputs), ...registered.inputs],
|
||||
outputs: [...registered.outputs, ...execPorts(c.derivedOutputs)],
|
||||
};
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def, c.entry ?? 'exec'), ctx };
|
||||
};
|
||||
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
@@ -88,6 +95,49 @@ const CASES: Record<string, Case[]> = {
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
StateBranch: [
|
||||
{ name: 'in no state yet, the flow ends', inputs: { machine: 'm' }, derivedOutputs: ['s1'], next: null },
|
||||
{
|
||||
name: 'follows the output of the current state',
|
||||
inputs: { machine: 'm' },
|
||||
context: { states: { m: 's1' } },
|
||||
derivedOutputs: ['s1', 's2'],
|
||||
next: 's1',
|
||||
},
|
||||
{
|
||||
name: 'a state that is not among its outputs (another machine, a deleted state) ends the flow',
|
||||
inputs: { machine: 'm' },
|
||||
context: { states: { m: 'gone' } },
|
||||
derivedOutputs: ['s1'],
|
||||
next: null,
|
||||
},
|
||||
],
|
||||
SetState: [
|
||||
{
|
||||
name: 'puts the machine into the state whose input the flow arrived through',
|
||||
inputs: { machine: 'm' },
|
||||
derivedInputs: ['s1', 's2'],
|
||||
entry: 's2',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({ m: 's2' }),
|
||||
},
|
||||
{
|
||||
name: 'arriving through no state input changes nothing',
|
||||
inputs: { machine: 'm' },
|
||||
derivedInputs: ['s1'],
|
||||
entry: 'exec',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({}),
|
||||
},
|
||||
{
|
||||
name: 'without a machine there is nothing to set',
|
||||
inputs: { machine: '' },
|
||||
derivedInputs: ['s1'],
|
||||
entry: 's1',
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(ctx.states).toEqual({}),
|
||||
},
|
||||
],
|
||||
Repeat: [
|
||||
{
|
||||
name: 'asks for as many body rounds as it was given, then continues at done',
|
||||
@@ -227,6 +277,10 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
|
||||
{ name: 'unset is no picture', outputs: { value: '' } },
|
||||
],
|
||||
StateMachine: [
|
||||
{ name: 'outputs the id it was set to', params: { value: 'sm-1' }, outputs: { value: 'sm-1' } },
|
||||
{ name: 'unset is no machine', outputs: { value: '' } },
|
||||
],
|
||||
Add: [
|
||||
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
|
||||
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
type DeclaredPort,
|
||||
GraphInterpreter,
|
||||
inlineInputDef,
|
||||
NO_BEHAVIORS,
|
||||
NO_LOOKUP,
|
||||
OBJECT_BEHAVIOR_PARAM,
|
||||
OBJECT_TYPE_PARAM,
|
||||
propertyPort,
|
||||
resolveDef,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
@@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) {
|
||||
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, props: PropertyBag) {
|
||||
const self = { ...fakeEntity('self'), props: { ...props } };
|
||||
const ctx = fakeContext({ self, variables: { seen: 0 } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
return { props: self.props, seen: ctx.variables.seen };
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('a node that reads and writes properties of an object', () => {
|
||||
[add, 'result', coins, 'coins'],
|
||||
]);
|
||||
const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ }));
|
||||
expect(self_.props.coins).toBe(7);
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe('a node that reads and writes properties of an object', () => {
|
||||
it('has flow pins only while a property is set, and they sit above the object input', () => {
|
||||
const { behavior, props } = onSelf([set('width'), { key: 'height', kind: 'number' }]);
|
||||
const keys = () => {
|
||||
const def = resolveDef(props, behavior.graph, NO_BEHAVIORS)!;
|
||||
const def = resolveDef(props, behavior.graph, NO_LOOKUP)!;
|
||||
return { inputs: def.inputs.map((p) => p.key), outputs: def.outputs.map((p) => p.key) };
|
||||
};
|
||||
expect(keys()).toEqual({ inputs: ['exec', 'object', 'width'], outputs: ['exec', 'width', 'height'] });
|
||||
@@ -147,7 +147,7 @@ describe('what a port carries, by the type of the node', () => {
|
||||
const edit = graphEditor(fixture, makeBehavior(fixture, 'B'));
|
||||
const hidden = (params: PropertyBag) => {
|
||||
const node = edit.properties([], params);
|
||||
return resolveDef(node, { nodes: [node], edges: [] }, NO_BEHAVIORS)!.hiddenParams?.(node.params);
|
||||
return resolveDef(node, { nodes: [node], edges: [] }, NO_LOOKUP)!.hiddenParams?.(node.params);
|
||||
};
|
||||
expect(hidden(SPRITE)).toEqual([OBJECT_BEHAVIOR_PARAM]);
|
||||
expect(hidden({})).toEqual([OBJECT_BEHAVIOR_PARAM]);
|
||||
@@ -166,7 +166,7 @@ describe('what a port carries, by the type of the node', () => {
|
||||
edit.properties([width], SPRITE),
|
||||
edit.properties([], SPRITE),
|
||||
];
|
||||
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_BEHAVIORS)!.label);
|
||||
const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_LOOKUP)!.label);
|
||||
expect(new Set(titles())).toEqual(new Set(['Properties']));
|
||||
fixture.stack.execute(
|
||||
new CollapseNodesCommand(
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* A state machine is an asset whose graph holds one `State` node per state. A behavior reads it
|
||||
* through `State branch` (one exec output per state) and moves it through `Set state` (one exec
|
||||
* input per state); the current state lives with the behavior instance, like its variables.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand, ConnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
BEHAVIOR_PARAM,
|
||||
BEHAVIOR_START,
|
||||
GraphInterpreter,
|
||||
MACHINE_PORT,
|
||||
nextStateName,
|
||||
resolveDef,
|
||||
STATE,
|
||||
STATE_NAME_PARAM,
|
||||
statesOf,
|
||||
} from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import type { StateMachineAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||
import {
|
||||
fakeContext,
|
||||
graphEditor,
|
||||
makeBehavior,
|
||||
makeProject,
|
||||
makeSpriteScene,
|
||||
place,
|
||||
type ProjectFixture,
|
||||
roundTrip,
|
||||
startWorld,
|
||||
} from '../helpers';
|
||||
|
||||
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
|
||||
function makeMachine(fixture: ProjectFixture, names = ['Open', 'Closed']) {
|
||||
const create = new CreateAssetCommand('statemachine', 'Doors');
|
||||
fixture.stack.execute(create);
|
||||
const ids = names.map((name) => {
|
||||
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
|
||||
fixture.stack.execute(add);
|
||||
return add.node.id;
|
||||
});
|
||||
return {
|
||||
id: create.asset.id,
|
||||
asset: () => findAssetOf(fixture.project, create.asset.id, 'statemachine')!,
|
||||
ids,
|
||||
};
|
||||
}
|
||||
|
||||
describe('a state machine asset', () => {
|
||||
it('is created empty, is not placeable, and takes states as nodes', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
expect(isPlaceableType('statemachine')).toBe(false);
|
||||
expect(statesOf(machine.asset().graph)).toEqual([
|
||||
{ id: machine.ids[0], name: 'Open' },
|
||||
{ id: machine.ids[1], name: 'Closed' },
|
||||
]);
|
||||
f.stack.undo();
|
||||
expect(statesOf(machine.asset().graph).map((s) => s.name)).toEqual(['Open']);
|
||||
});
|
||||
|
||||
it('names a state by its setting, and falls back to "State" for an empty name', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f, ['Open']);
|
||||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, ''));
|
||||
expect(statesOf(machine.asset().graph)[0]?.name).toBe('State');
|
||||
const node = machine.asset().graph.nodes[0]!;
|
||||
expect(resolveDef(node, machine.asset().graph, graphLookup(f.project))?.label).toBe('State');
|
||||
});
|
||||
|
||||
it('survives a save and load, keeping only its states', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const loaded = findAssetOf(roundTrip(f.project), machine.id, 'statemachine');
|
||||
expect(loaded && statesOf(loaded.graph)).toEqual(statesOf(machine.asset().graph));
|
||||
|
||||
// A node of any other type has no business in a machine and is dropped on load.
|
||||
const smuggled = deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'm',
|
||||
type: 'statemachine',
|
||||
name: 'M',
|
||||
graph: {
|
||||
nodes: [
|
||||
{ id: 's', type: STATE, x: 0, y: 0, params: { name: 'Idle' } },
|
||||
{ id: 'n', type: 'Number', x: 0, y: 0, params: { value: 1 } },
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const asset = findAsset(smuggled, 'm') as StateMachineAsset;
|
||||
expect(asset.graph.nodes.map((n) => n.type)).toEqual([STATE]);
|
||||
});
|
||||
|
||||
it('keeps its State node out of every behavior palette: it is registered, not built in', () => {
|
||||
expect(BUILT_IN_NODES.some((d) => d.type === STATE)).toBe(false);
|
||||
expect(BUILT_IN_NODES.some((d) => d.category === 'state')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers one palette entry, a State named by its number', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const entries = paletteEntries(f.project, machine.asset());
|
||||
expect(entries.map((e) => e.def.type)).toEqual([STATE]);
|
||||
expect(entries[0]?.params).toEqual({ [STATE_NAME_PARAM]: 'State 3' });
|
||||
expect(nextStateName(machine.asset().graph)).toBe('State 3');
|
||||
f.stack.undo();
|
||||
expect(nextStateName(machine.asset().graph)).toBe('State 2');
|
||||
});
|
||||
|
||||
it('is a kind of variable holding the asset id', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||||
const back = findAssetOf(roundTrip(f.project), behavior.id, 'behavior');
|
||||
expect(back?.variables.map((v) => `${v.key}:${v.kind}`)).toEqual(['doors:statemachine']);
|
||||
expect(back?.props.doors).toBe(machine.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the state ports of State branch and Set state', () => {
|
||||
const setup = () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const ports = (node: { id: string }, side: 'inputs' | 'outputs') =>
|
||||
resolveDef(
|
||||
behavior.graph.nodes.find((n) => n.id === node.id)!,
|
||||
behavior.graph,
|
||||
graphLookup(f.project),
|
||||
)!
|
||||
[side].filter((p) => p.type === 'exec')
|
||||
.map((p) => `${p.key}:${p.label}`);
|
||||
return { f, machine, behavior, edit, ports };
|
||||
};
|
||||
|
||||
it('are one exec port per state, keyed by the state id and labelled by its name', () => {
|
||||
const { machine, edit, ports } = setup();
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
const expected = [`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`];
|
||||
expect(ports(branch, 'outputs')).toEqual(expected);
|
||||
expect(ports(set, 'inputs')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('follow a State machine literal wired into the machine input', () => {
|
||||
const { machine, edit, ports } = setup();
|
||||
const literal = edit.addNode('StateMachine', { value: machine.id });
|
||||
const branch = edit.addNode('StateBranch');
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
edit.connect(literal, 'value', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([`${machine.ids[0]}:Open`, `${machine.ids[1]}:Closed`]);
|
||||
});
|
||||
|
||||
it('cannot follow a variable: a port is derived from the graph, not from a running value', () => {
|
||||
const { f, machine, behavior, edit, ports } = setup();
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'doors', 'statemachine'));
|
||||
findAsset(f.project, behavior.id)!.props.doors = machine.id;
|
||||
const variables = edit.variables(['doors']);
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
edit.connect(variables, 'doors', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
// Only the State machine literal is followed – a text holding the very same id is not.
|
||||
const text = edit.addNode('String', { value: machine.id });
|
||||
edit.connect(text, 'value', branch, MACHINE_PORT);
|
||||
expect(ports(branch, 'outputs')).toEqual([]);
|
||||
});
|
||||
|
||||
it('follow a rename, and lose the edges of a state that is deleted', () => {
|
||||
const { f, machine, behavior, edit, ports } = setup();
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
edit.connect(start, 'exec', set, machine.ids[0]!);
|
||||
f.stack.execute(new SetNodeParamCommand(machine.id, machine.ids[0]!, STATE_NAME_PARAM, 'Ajar'));
|
||||
expect(ports(set, 'inputs')[0]).toBe(`${machine.ids[0]}:Ajar`);
|
||||
expect(behavior.graph.edges).toHaveLength(1);
|
||||
// Picking another machine orphans the edge, as any param that decides the ports does.
|
||||
f.stack.execute(new SetNodeParamCommand(behavior.id, set.id, MACHINE_PORT, ''));
|
||||
expect(behavior.graph.edges).toHaveLength(0);
|
||||
f.stack.undo();
|
||||
expect(behavior.graph.edges).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a running state machine', () => {
|
||||
/** OnStart sets Open; OnUpdate branches: Open logs "open" and sets Closed, Closed logs "closed". */
|
||||
function build(f: ProjectFixture, machineId: string, [open, closed]: readonly string[]) {
|
||||
const behavior = makeBehavior(f, 'Door');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const setOpen = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||||
const update = edit.addNode('OnUpdate');
|
||||
const branch = edit.addNode('StateBranch', { [MACHINE_PORT]: machineId });
|
||||
const logOpen = edit.addNode('Log', { message: 'open' });
|
||||
const setClosed = edit.addNode('SetState', { [MACHINE_PORT]: machineId });
|
||||
const logClosed = edit.addNode('Log', { message: 'closed' });
|
||||
edit.wire([
|
||||
[start, 'exec', setOpen, open!],
|
||||
[update, 'exec', branch, 'exec'],
|
||||
[branch, open!, logOpen, 'exec'],
|
||||
[logOpen, 'exec', setClosed, closed!],
|
||||
[branch, closed!, logClosed, 'exec'],
|
||||
]);
|
||||
return behavior;
|
||||
}
|
||||
|
||||
it('is in no state until Set state ran, then follows the state it was put in', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = build(f, machine.id, machine.ids);
|
||||
const ctx = fakeContext();
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
expect(ctx.logs).toEqual([]);
|
||||
interpreter.fire('OnStart', ctx);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
interpreter.fire('OnUpdate', ctx);
|
||||
expect(ctx.logs).toEqual(['open', 'closed']);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
|
||||
});
|
||||
|
||||
it('keeps one state per behavior instance', () => {
|
||||
const f = makeSpriteScene();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = build(f, machine.id, machine.ids);
|
||||
place(f, behavior.id, { x: 0, y: 0 });
|
||||
place(f, behavior.id, { x: 10, y: 0 });
|
||||
const started = startWorld(f, { behaviors: true });
|
||||
started.frame();
|
||||
// Shared state would let the second instance find the door the first one just closed.
|
||||
expect(started.world.log).toEqual(['open', 'open']);
|
||||
started.frame();
|
||||
expect(started.world.log).toEqual(['open', 'open', 'closed', 'closed']);
|
||||
});
|
||||
|
||||
it('starts a called behavior in no state, whatever the caller set', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const inner = makeBehavior(f, 'Inner');
|
||||
const innerEdit = graphEditor(f, inner);
|
||||
const innerStart = innerEdit.addNode(BEHAVIOR_START);
|
||||
const branch = innerEdit.addNode('StateBranch', { [MACHINE_PORT]: machine.id });
|
||||
const log = innerEdit.addNode('Log', { message: 'inner saw open' });
|
||||
innerEdit.wire([
|
||||
[innerStart, 'exec', branch, 'exec'],
|
||||
[branch, machine.ids[0]!, log, 'exec'],
|
||||
]);
|
||||
const outer = makeBehavior(f, 'Outer');
|
||||
const edit = graphEditor(f, outer);
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
const call = edit.addNode('RunBehavior', { [BEHAVIOR_PARAM]: inner.id });
|
||||
edit.wire([
|
||||
[start, 'exec', set, machine.ids[0]!],
|
||||
[set, 'exec', call, 'exec'],
|
||||
]);
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx);
|
||||
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
|
||||
expect(ctx.logs).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses a connection to a state port the machine does not have', () => {
|
||||
const f = makeProject();
|
||||
const machine = makeMachine(f);
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
const edit = graphEditor(f, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const set = edit.addNode('SetState', { [MACHINE_PORT]: machine.id });
|
||||
expect(() =>
|
||||
f.stack.execute(
|
||||
new ConnectCommand(behavior.id, { node: start.id, port: 'exec' }, { node: set.id, port: 'nope' }),
|
||||
),
|
||||
).toThrow('Port not found');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCom
|
||||
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 { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
|
||||
import { graphEditor, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('seeding a node from that shape', () => {
|
||||
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
|
||||
stack.execute(new AddPortsCommand(behavior.id, pack.id, ports));
|
||||
|
||||
const inputs = resolveDef(pack, behavior.graph, behaviorLookup(fixture.project))!.inputs;
|
||||
const inputs = resolveDef(pack, behavior.graph, graphLookup(fixture.project))!.inputs;
|
||||
expect(inputs.map((p) => `${p.key}:${p.type}`)).toEqual([
|
||||
'shape:number',
|
||||
'name:string',
|
||||
|
||||
@@ -104,11 +104,12 @@ describe('the literal nodes', () => {
|
||||
/** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */
|
||||
it('are labelled by the kind they hand out', () => {
|
||||
const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label;
|
||||
expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([
|
||||
expect(['Number', 'Boolean', 'String', 'Texture', 'StateMachine', 'List'].map(label)).toEqual([
|
||||
'Number',
|
||||
'Boolean',
|
||||
'Text',
|
||||
'Texture',
|
||||
'State machine',
|
||||
'List',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ 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, resolveDef } from '../../src/core/graph';
|
||||
import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
function setup(variables = ['count', 'copy']) {
|
||||
@@ -19,12 +19,12 @@ function run(
|
||||
variables: Record<string, number> = { count: 5, copy: 0 },
|
||||
) {
|
||||
const ctx = fakeContext({ variables: { ...variables } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
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, () => null)![side].map((p) => p.key);
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => p.key);
|
||||
|
||||
const edgeCount = (behavior: BehaviorAsset) => behavior.graph.edges.length;
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('the Variables node', () => {
|
||||
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, () => null)!
|
||||
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: [] });
|
||||
@@ -237,7 +237,7 @@ describe('switching Set on a Variables node', () => {
|
||||
|
||||
describe('Variables node titles', () => {
|
||||
const titleOf = (behavior: BehaviorAsset, node: GraphNode) =>
|
||||
resolveDef(node, behavior.graph, () => null)!.label;
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)!.label;
|
||||
|
||||
it('follow a renamed variable once collapsed', () => {
|
||||
const { fixture, behavior, edit } = setup();
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
} from '../../src/core/commands/variableCommands';
|
||||
import { type GraphNode, GraphInterpreter, resolveDef, VARIABLES } from '../../src/core/graph';
|
||||
import { type GraphNode, GraphInterpreter, NO_LOOKUP, resolveDef, VARIABLES } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
|
||||
@@ -41,14 +41,14 @@ function run(
|
||||
fixture: ReturnType<typeof makeProject>,
|
||||
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
|
||||
) {
|
||||
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project));
|
||||
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
|
||||
const ctx = fakeContext({ variables });
|
||||
interpreter.fire('OnStart', ctx);
|
||||
return ctx.variables;
|
||||
}
|
||||
|
||||
const portKeys = (behavior: BehaviorAsset, node: GraphNode, side: 'inputs' | 'outputs') =>
|
||||
resolveDef(node, behavior.graph, () => null)![side].map((p) => `${p.key}:${p.type}`);
|
||||
resolveDef(node, behavior.graph, NO_LOOKUP)![side].map((p) => `${p.key}:${p.type}`);
|
||||
|
||||
describe('a node that reads or writes several variables at once', () => {
|
||||
it('writes each variable it names and reads each one back', () => {
|
||||
@@ -139,7 +139,7 @@ describe('a node that reads or writes several variables at once', () => {
|
||||
]);
|
||||
|
||||
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
|
||||
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
|
||||
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
|
||||
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -181,6 +181,7 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
||||
self: fakeEntity('self'),
|
||||
owner: fakeEntity('owner'),
|
||||
variables: { speed: 100 },
|
||||
states: {},
|
||||
time: 0,
|
||||
dt: 0.5,
|
||||
logs,
|
||||
@@ -213,9 +214,10 @@ export function runDef(
|
||||
ctx: NodeContext,
|
||||
inputs: Record<string, Value>,
|
||||
params: PropertyBag = {},
|
||||
entry = 'exec',
|
||||
): RunResult {
|
||||
const def = requireNodeDef(type);
|
||||
return def.run!(ctx, inputs, params, def);
|
||||
return def.run!(ctx, inputs, params, def, entry);
|
||||
}
|
||||
|
||||
/** Runs one node definition in isolation and returns its data outputs. */
|
||||
|
||||
Reference in new issue
Block a user