Files
feuerwerk/tests/core/start-machine.test.ts
T
adminandClaude Opus 5.5 d80d5cce26 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>
2026-10-04 18:15:56 +02:00

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