Files
feuerwerk/tests/core/dynamic-ports.test.ts
T
rootandClaude Opus 5 8f92a06c84 Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus;
the port and marker menus were the same list and are now one function.
Value specs of the inspector and the list tab share valueSpecs.ts, and
the remaining copies (compound commands, type glyphs, typed asset
lookup, name rule, play/editor world setup, marquee) are merged.

Four copies had already drifted apart; merging them fixes:
- "Disconnect N links" in the port menu is one undo step, not N
- "+ Add collider" is hidden on a placed container instance, as in
  the hierarchy menu, instead of always failing
- a bool property coerced from the text "false" becomes false
- a port command on an unknown node type reports instead of crashing

Comment-only edits leave the bundle byte-identical. README corrected in
35 places (step limit, jump-run clock, suite duration, 409 body, ...).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:00:30 +02:00

160 lines
6.9 KiB
TypeScript

/** Ports a node works out for itself (`NodeDef.derivePorts`): from its settings, or from its own edges. */
import { describe, expect, it } from 'vitest';
import { AddPortCommand } from '../../src/core/commands/portCommands';
import { DisconnectCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import {
BEHAVIOR_END,
BEHAVIOR_START,
type GraphNode,
GraphInterpreter,
inlineInputs,
requireNodeDef,
resolveDef,
} from '../../src/core/graph';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
function sequenceFixture() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
const edit = graphEditor(fixture, behavior);
return { ...fixture, behavior, edit };
}
const portsOf = (behavior: BehaviorAsset, node: GraphNode, fixture: ProjectFixture) =>
resolveDef(node, behavior.graph, behaviorLookup(fixture.project))!.outputs.map((p) => p.key);
const edgeKeys = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.port}→${e.to.port}`).sort();
describe('a Sequence sized by what is plugged into it', () => {
it('starts with one output and grows a spare as each last one is connected', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
edit.connect(node, 'a', edit.addNode('Log'), 'exec');
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
edit.connect(node, 'b', edit.addNode('Log'), 'exec');
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
});
it('shrinks again when the bottom edge goes, and never below the last connected one', () => {
const fixture = sequenceFixture();
const { stack, behavior, edit } = fixture;
const node = edit.addNode('Sequence');
edit.wire(['a', 'b'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c']);
stack.undo();
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b']);
stack.undo();
expect(portsOf(behavior, node, fixture)).toEqual(['a']);
});
it('keeps a gap rather than dropping an edge: the last connected port sets the size', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
edit.wire(['a', 'b', 'c'].map((port) => [node, port, edit.addNode('Log'), 'exec'] as const));
const middle = behavior.graph.edges.find((e) => e.from.port === 'b')!;
fixture.stack.execute(new DisconnectCommand(behavior.id, middle.id));
expect(portsOf(behavior, node, fixture)).toEqual(['a', 'b', 'c', 'd']);
expect(edgeKeys(behavior)).toEqual(['a→exec', 'c→exec']);
});
it('runs the outputs it has, in order', () => {
const fixture = sequenceFixture();
const { behavior, edit } = fixture;
const node = edit.addNode('Sequence');
const onStart = edit.addNode('OnStart');
edit.connect(onStart, 'exec', node, 'exec');
for (const port of ['a', 'b'])
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.logs).toEqual(['A', 'B']);
});
});
/** The point of a struct: a bundle crosses a behavior boundary on ONE edge. */
describe('a struct travelling through a nested behavior', () => {
it('goes in as one port and comes back out as its parts', () => {
const fixture = makeProject();
const callee = makeBehavior(fixture, 'Sum piece');
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: 'piece', kind: 'struct' }));
fixture.stack.execute(new AddPortCommand(callee.id, end.id, { key: 'total', kind: 'number' }));
const expand = inner.addNode('ExpandStruct');
for (const key of ['x', 'y'])
fixture.stack.execute(new AddPortCommand(callee.id, expand.id, { key, kind: 'number' }));
const add = inner.addNode('Add');
inner.wire([
[start, 'exec', end, 'exec'],
[start, 'piece', expand, 'value'],
[expand, 'x', add, 'a'],
[expand, 'y', add, 'b'],
[add, 'result', end, 'total'],
]);
const caller = makeBehavior(fixture, 'Caller', ['result']);
const outer = graphEditor(fixture, caller);
const pack = outer.addNode('PackStruct', { x: 30, y: 12 });
for (const key of ['x', 'y'])
fixture.stack.execute(new AddPortCommand(caller.id, pack.id, { key, kind: 'number' }));
const onStart = outer.addNode('OnStart');
const call = outer.addNode('RunBehavior', { behavior: callee.id });
const set = outer.addNode('SetVariable', { variable: 'result' });
outer.wire([
[onStart, 'exec', call, 'exec'],
[pack, 'value', call, 'piece'],
[call, 'exec', set, 'exec'],
[call, 'total', set, 'value'],
]);
// 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);
expect(ctx.variables.result).toBe(42);
});
});
describe('a struct input', () => {
it('is offered no inline field, because a bundle cannot be typed on one line', () => {
const def = requireNodeDef('ExpandStruct');
expect(def.inputs.map((p) => p.type)).toEqual(['struct']);
expect(inlineInputs(def)).toEqual([]);
});
});
describe('a Run behavior node whose behavior changes', () => {
it('drops the edges of the ports the new behavior does not have', () => {
const fixture = makeProject();
const called = makeBehavior(fixture, 'Called');
const other = makeBehavior(fixture, 'Other');
const start = graphEditor(fixture, called).addNode(BEHAVIOR_START);
fixture.stack.execute(new AddPortCommand(called.id, start.id, { key: 'power', kind: 'number' }));
const caller = makeBehavior(fixture, 'Caller');
const edit = graphEditor(fixture, caller);
const call = edit.addNode('RunBehavior', { behavior: called.id });
const number = edit.addNode('Number', { value: 3 });
edit.connect(number, 'value', call, 'power');
expect(edgeKeys(caller)).toEqual(['value→power']);
fixture.stack.execute(new SetNodeParamCommand(caller.id, call.id, 'behavior', other.id));
expect(edgeKeys(caller)).toEqual([]);
fixture.stack.undo();
expect(edgeKeys(caller)).toEqual(['value→power']);
expect(start.ports).toEqual([{ key: 'power', kind: 'number' }]);
});
});