Files
feuerwerk/tests/core/live-values.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

115 lines
4.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
BEHAVIOR_END,
BEHAVIOR_PARAM,
BEHAVIOR_START,
type Graph,
GraphInterpreter,
RUN_BEHAVIOR,
type TraceListener,
type Value,
} from '../../src/core/graph';
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';
import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers';
function recorder() {
const seen = new Map<Graph, Map<string, Record<string, Value>>>();
const listener: TraceListener = {
onEdge: () => {},
onNode: () => {},
onValues: (graph, id, values) => {
const byNode = seen.get(graph) ?? new Map<string, Record<string, Value>>();
seen.set(graph, byNode.set(id, values));
},
};
return { seen, listener };
}
describe('live values', () => {
it('record what each node last produced, a read node after its own write', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['count']);
const edit = graphEditor(fixture, behavior);
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'],
]);
const { seen, listener } = recorder();
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
interpreter.trace = listener;
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
interpreter.fire('OnStart', ctx);
ctx.variables.count = 99;
const values = seen.get(behavior.graph)!;
expect(values.get(add.id)).toEqual({ result: 6 });
expect(values.get(count.id)).toEqual({ count: 6 });
});
it('keep a nested behavior apart, under its own graph', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'Double');
const inner = graphEditor(fixture, callee);
const start = inner.addNode(BEHAVIOR_START);
const end = inner.addNode(BEHAVIOR_END);
fixture.stack.execute(new AddPortCommand(callee.id, start.id, { key: 'in1', kind: 'number' }));
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'out1', kind: 'number' }));
const times = inner.addNode('Multiply', { b: 2 });
inner.wire([
[start, 'exec', end, 'exec'],
[start, 'in1', times, 'a'],
[times, 'result', end, 'out1'],
]);
const caller = makeBehavior(fixture, 'Caller');
const outer = graphEditor(fixture, caller);
const onStart = outer.addNode('OnStart');
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, graphLookup(fixture.project));
interpreter.trace = listener;
interpreter.fire('OnStart', fakeContext());
expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 });
expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 });
expect(seen.get(caller.graph)?.get(call.id)).toEqual({ out1: 42 });
expect(seen.get(caller.graph)?.has(times.id)).toBe(false);
});
it('show the instance that produced last, not the last one in the list', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'Counter', ['count']);
const edit = graphEditor(f, behavior);
const update = edit.addNode('OnUpdate');
const count = edit.variables(['count'], { write: true });
const add = edit.addNode('Add', { b: 1 });
edit.wire([
[update, 'exec', count, 'exec'],
[count, 'count', add, 'a'],
[add, 'result', count, 'count'],
]);
place(f, behavior.id, { x: 0, y: 0 });
const second = place(f, behavior.id, { x: 0, y: 0 });
f.stack.execute(new SetOverrideCommand(f.sceneId, second, 'count', 10));
const world = World.instantiate(f.project, f.sceneId);
const system = new BehaviorSystem(f.project, new Input());
world.addSystem(system);
world.tick(1 / 60);
expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 11 });
world.entities.find((e) => e.objectId === second)!.props.enabled = false;
world.tick(1 / 60);
expect(system.traceFor(behavior.id).values.get(count.id)).toEqual({ count: 2 });
});
});