Let a Start input or a variable name the state machine it carries

A sub-behavior that gets its machine through a Start input had no
state ports on Is in state, Set state and the other state nodes.
Their ports came only from an inline pick or a State machine literal,
so a machine handed in from outside left them empty.

- A statemachine port of a Start node can now name a machine
  (DeclaredPort.machine). The inspector shows the pick next to the
  port name. SetPortMachineCommand sets it with undo.
- machineIdOf follows an edge from such a port to that machine, so
  the state nodes it feeds offer its states.
- A Run behavior node has the machine picked in advance on that input.
- A different pick there is allowed, but the node warns, because the
  called graph asks for the states of its own machine.
- A statemachine variable of a Variables node can name a machine
  the same way, picked next to the variable's row.
- The inspector signature of a node now includes the picked machine.
  Before, the pick changed the ports but the dropdown kept its text
  until the node was selected again.
- A local machine handed in still starts in no state every call.
  That rule from #229 is unchanged.

Tests:
- tests/core/start-machine.test.ts (7): ports with and without a
  machine and undo, save and load, the preset value moving a shared
  machine through the call, the warning, a port of another kind,
  the inspector signature, a Variables node.
- Counter checks: without the new branch in machineIdOf 2 tests
  fail, without the machine in the signature 1 test fails.
- Gate on melektaus-atx: 239 files, 3599 tests, CHECK EXIT 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-04 18:15:56 +02:00
1 parent 3f12a77aea
commit d80d5cce26
8 files changed
+284 -10

No files matched your search

+159
View File
@@ -0,0 +1,159 @@
/**
* A `statemachine` input of a behavior's start node can say which machine it carries. The state
* nodes it feeds then offer that machine's states, and a node running the behavior has it picked
* in advance – a different pick there is a problem on that node. A variable a Variables node reads
* can say the same.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { AddPortCommand, SetPortMachineCommand } from '../../src/core/commands/portCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
BEHAVIOR_PARAM,
BEHAVIOR_START,
callProblem,
GraphInterpreter,
inlineValue,
MACHINE_PORT,
resolveDef,
RUN_BEHAVIOR,
STATE,
STATE_NAME_PARAM,
} from '../../src/core/graph';
import { graphLookup } from '../../src/core/model/graphLookup';
import { findAssetOf } from '../../src/core/model/Project';
import { nodeSignature } from '../../src/editor/inspector/nodeSection';
import {
fakeContext,
fire,
graphEditor,
makeBehavior,
makeProject,
type ProjectFixture,
roundTrip,
} from '../helpers';
function makeMachine(f: ProjectFixture, name: string, states: readonly string[]) {
const create = new CreateAssetCommand('statemachine', name);
f.stack.execute(create);
const ids = states.map((state) => {
const add = new AddNodeCommand(create.asset.id, STATE, 0, 0, { [STATE_NAME_PARAM]: state });
f.stack.execute(add);
return add.node.id;
});
return { id: create.asset.id, ids };
}
/** "Inner": its start hands `machine` to an `Is in state` and to a `Set state`. */
function build(f: ProjectFixture) {
const doors = makeMachine(f, 'Doors', ['Open', 'Closed']);
const lights = makeMachine(f, 'Lights', ['On']);
const inner = makeBehavior(f, 'Inner');
const edit = graphEditor(f, inner);
const start = edit.addNode(BEHAVIOR_START);
f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'machine', kind: 'statemachine' }));
const isIn = edit.addNode('IsInState');
const set = edit.addNode('SetState');
edit.wire([
[start, 'machine', isIn, MACHINE_PORT],
[start, 'machine', set, MACHINE_PORT],
]);
const ports = (node = isIn) =>
resolveDef(node, inner.graph, graphLookup(f.project))!.outputs.map((p) => p.label);
return { doors, lights, inner, start, isIn, set, ports };
}
describe('a machine carried by a start node input', () => {
it('gives the nodes it feeds the states of that machine, and none without one', () => {
const f = makeProject();
const { doors, inner, start, ports } = build(f);
expect(ports()).toEqual([]);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id));
expect(ports()).toEqual(['Open', 'Closed']);
f.stack.undo();
expect(ports()).toEqual([]);
expect(start.ports).toEqual([{ key: 'machine', kind: 'statemachine' }]);
});
it('survives a save and load', () => {
const f = makeProject();
const { doors, inner, start } = build(f);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id));
const loaded = findAssetOf(roundTrip(f.project), inner.id, 'behavior')!;
const port = loaded.graph.nodes.find((n) => n.id === start.id)!.ports;
expect(port).toEqual([{ key: 'machine', kind: 'statemachine', machine: doors.id }]);
});
it('is picked in advance on a node running the behavior, and moves that machine', () => {
const f = makeProject();
const { doors, inner, start, set } = build(f);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id));
graphEditor(f, inner).connect(start, 'exec', set, doors.ids[1]!);
const outer = makeBehavior(f, 'Outer');
const edit = graphEditor(f, outer);
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id });
edit.connect(edit.addNode('OnStart'), 'exec', call, 'exec');
const lookup = graphLookup(f.project);
const input = resolveDef(call, outer.graph, lookup)!.inputs.find((p) => p.key === 'machine')!;
expect(inlineValue(call, input)).toBe(doors.id);
// Shared, so the move outlives the call and can be read here.
const ctx = fakeContext({ shared: { variables: {}, states: { [doors.id]: '' } } });
fire(new GraphInterpreter(outer.graph, lookup), 'OnStart', ctx);
expect(ctx.shared.states[doors.id]).toBe(doors.ids[1]);
});
it('warns on a node that picks a different machine, not on one picking the same', () => {
const f = makeProject();
const { doors, lights, inner, start } = build(f);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id));
const outer = makeBehavior(f, 'Outer');
const edit = graphEditor(f, outer);
const call = edit.addNode(RUN_BEHAVIOR, { [BEHAVIOR_PARAM]: inner.id });
const lookup = () => graphLookup(f.project);
expect(callProblem(call, lookup(), outer.id)).toBeNull();
call.params.machine = lights.id;
expect(callProblem(call, lookup(), outer.id)).toBe('"machine" expects "Doors"');
call.params.machine = doors.id;
expect(callProblem(call, lookup(), outer.id)).toBeNull();
});
it('is refused on a port of another kind', () => {
const f = makeProject();
const { doors, inner, start } = build(f);
f.stack.execute(new AddPortCommand(inner.id, start.id, { key: 'speed', kind: 'number' }));
expect(() => f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'speed', doors.id))).toThrow(
'"speed" is not a state machine',
);
});
it('rebuilds the inspector of the start node when it changes', () => {
const f = makeProject();
const { doors, lights, inner, start } = build(f);
const before = nodeSignature(f.project, inner.id, start.id);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', doors.id));
const picked = nodeSignature(f.project, inner.id, start.id);
expect(picked).not.toBe(before);
f.stack.execute(new SetPortMachineCommand(inner.id, start.id, 'machine', lights.id));
expect(nodeSignature(f.project, inner.id, start.id)).not.toBe(picked);
});
});
describe('a machine carried by a variable of a Variables node', () => {
it('gives the nodes it feeds the states of that machine, and none without one', () => {
const f = makeProject();
const doors = makeMachine(f, 'Doors', ['Open', 'Closed']);
const behavior = makeBehavior(f, 'Door');
f.stack.execute(new AddVariableCommand(behavior.id, 'door', 'statemachine'));
const edit = graphEditor(f, behavior);
const read = edit.variables(['door']);
const isIn = edit.addNode('IsInState');
edit.connect(read, 'door', isIn, MACHINE_PORT);
const ports = () => resolveDef(isIn, behavior.graph, graphLookup(f.project))!.outputs.map((p) => p.label);
expect(ports()).toEqual([]);
f.stack.execute(new SetPortMachineCommand(behavior.id, read.id, 'door', doors.id));
expect(ports()).toEqual(['Open', 'Closed']);
f.stack.undo();
expect(ports()).toEqual([]);
});
});