Files
feuerwerk/tests/core/variable-ports.test.ts
T
rootandClaude Opus 5 854ae2e756 Add Get/Set properties nodes and a breadcrumb for opened sub-behaviors
Get properties / Set properties work like their variable counterparts:
one port per chosen property, an unconnected input writes its inline
value. A port chosen from the built-in list keeps that property's field
and default (height starts at 100, color is a colour field). "object" is
reserved for the fixed input, checked by the command and on load.

The property picker names the owning type ("Sprite width"), and text
fields can now carry a suggestion list; Get/Set property use it.

Double-clicking a call node shows the path of opened behaviors under the
graph header. Clicking an entry jumps back and trims the path; choosing
a behavior any other way hides it. Reopening a behavior already on the
path steps back to it instead of repeating it. The bar shows before the
graph switches, otherwise it clips the graph that was just fitted.

The "Add node..." header button is gone; nodes come from the context menu.

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

207 lines
9.7 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.
/**
* Get/Set variables: the two nodes whose declared ports are variables of the behavior around them,
* rather than names of their own. What that costs is the wiring – a port has to follow the variable
* it names when that is renamed or swapped, and take its edges along.
*/
import { describe, expect, it } from 'vitest';
import { AddPortCommand, RepointPortCommand } from '../../src/core/commands/portCommands';
import {
AddStructFieldCommand,
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../src/core/commands/variableCommands';
import {
GET_VARIABLES,
type GraphNode,
GraphInterpreter,
resolveDef,
SET_VARIABLES,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
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`. */
function bothWays() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES, { px: 7, py: 9 });
const get = edit.addNode(GET_VARIABLES);
const add = edit.addNode('Add');
const store = edit.addNode('SetVariable', { variable: 'sum' });
for (const node of [set, get])
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key, kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[set, 'exec', store, 'exec'],
[get, 'px', add, 'a'],
[get, 'py', add, 'b'],
[add, 'result', store, 'value'],
]);
return { fixture, behavior, set, get, edit };
}
function run(
behavior: BehaviorAsset,
fixture: ReturnType<typeof makeProject>,
variables: Record<string, number> = { px: 0, py: 0, sum: 0 },
) {
const interpreter = new GraphInterpreter(behavior.graph, behaviorLookup(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}`);
describe('a node that reads or writes several variables at once', () => {
it('writes each variable it names and reads each one back', () => {
const { fixture, behavior } = bothWays();
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('takes the port type from the variable, so a number output stays a number', () => {
const { fixture, behavior, get, set } = bothWays();
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'alive', kind: 'boolean' }));
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number', 'alive:boolean']);
// The exec port is the node's own; only the variables come from the declaration.
expect(portKeys(behavior, set, 'inputs')).toEqual(['exec:exec', 'px:number', 'py:number']);
});
it('follows a renamed variable, edge and all', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'px', 'column'));
expect((get.ports ?? []).map((p) => p.key)).toEqual(['column', 'py']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'column')).toBe(true);
expect(run(behavior, fixture, { column: 0, py: 0, sum: 0 })).toEqual({ column: 7, py: 9, sum: 16 });
fixture.stack.undo();
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
it('keeps the edge when a port is pointed at another variable', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new AddVariableCommand(behavior.id, 'alive', 'boolean'));
fixture.stack.execute(
new RepointPortCommand(behavior.id, get.id, 'px', { key: 'alive', kind: 'boolean' }),
);
expect(portKeys(behavior, get, 'outputs')).toEqual(['alive:boolean', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'alive')).toBe(true);
fixture.stack.undo();
expect(portKeys(behavior, get, 'outputs')).toEqual(['px:number', 'py:number']);
expect(behavior.graph.edges.some((e) => e.from.node === get.id && e.from.port === 'px')).toBe(true);
});
it('keeps a port whose variable was removed, so undoing the removal restores the graph', () => {
const { fixture, behavior, get } = bothWays();
fixture.stack.execute(new RemoveVariableCommand(behavior.id, 'px'));
// Left standing rather than dropped: taking the port would have to take its edge too, and
// undo could not put either back. The inspector marks it as naming no variable.
expect((get.ports ?? []).map((p) => p.key)).toEqual(['px', 'py']);
fixture.stack.undo();
expect(behavior.variables.map((v) => v.key)).toEqual(['px', 'py', 'sum']);
expect(run(behavior, fixture)).toEqual({ px: 7, py: 9, sum: 16 });
});
/**
* The rule that decides how far a `Get variables` may reach: a data node answers once per pulse
* and that answer is kept, so a single node cannot straddle a write of one of its variables – the
* later reader would be served the value from before it. A second node is the way across.
*/
it('answers once per pulse, so a reader past a write needs a node of its own', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py', 'sum', 'fresh']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const early = edit.addNode(GET_VARIABLES);
const late = edit.addNode(GET_VARIABLES);
const write = edit.addNode(SET_VARIABLES, { px: 7 });
const before = edit.addNode('SetVariable', { variable: 'sum' });
const after = edit.addNode('SetVariable', { variable: 'py' });
const again = edit.addNode('SetVariable', { variable: 'fresh' });
for (const node of [early, late, write])
fixture.stack.execute(new AddPortCommand(behavior.id, node.id, { key: 'px', kind: 'number' }));
edit.wire([
[start, 'exec', before, 'exec'],
[before, 'exec', write, 'exec'],
[write, 'exec', after, 'exec'],
[after, 'exec', again, 'exec'],
[early, 'px', before, 'value'],
[early, 'px', after, 'value'],
[late, 'px', again, 'value'],
]);
// `py` is the point: the same node read past the write still answers 1, while `late` sees 7.
expect(run(behavior, fixture, { px: 1, py: 0, sum: 0, fresh: 0 })).toEqual({
px: 7,
py: 1,
sum: 1,
fresh: 7,
});
});
it('survives a save and load, which is what makes the pick more than a session', () => {
const { fixture, behavior } = bothWays();
const loaded = roundTrip(fixture.project);
const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
const set = asset.graph.nodes.find((n) => n.type === SET_VARIABLES)!;
expect((set.ports ?? []).map((p) => `${p.key}:${p.kind}`)).toEqual(['px:number', 'py:number']);
expect(run(asset, { project: loaded, stack: fixture.stack })).toEqual({ px: 7, py: 9, sum: 16 });
});
/**
* A struct port writes the way the singular node does: the bundle it is handed is merged in, so a
* `Pack` of one field leaves the rest standing. Tetris leans on this – each strand of `Next move`
* packs only the field it changes and lets the plural node keep the other three.
*/
it('merges a partial bundle into a struct variable instead of replacing it', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
for (const key of ['shape', 'rot'])
fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key, kind: 'number' }));
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const set = edit.addNode(SET_VARIABLES);
const pack = edit.addNode('PackStruct', { rot: 2 });
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'piece', kind: 'struct' }));
fixture.stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'rot', kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[pack, 'value', set, 'piece'],
]);
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
new GraphInterpreter(behavior.graph, behaviorLookup(fixture.project)).fire('OnStart', ctx);
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
});
/**
* The other half of how far a plural node may reach: it collects every input before it writes any
* of them, so an input fed from a variable the same node writes sees the value from before.
*/
it('reads every input before it writes any, so a plural set cannot feed itself', () => {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['px', 'py']);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const read = edit.addNode('GetVariable', { variable: 'px' });
const set = edit.addNode(SET_VARIABLES, { px: 7 });
for (const key of ['px', 'py'])
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
edit.wire([
[start, 'exec', set, 'exec'],
[read, 'value', set, 'py'],
]);
expect(run(behavior, fixture, { px: 1, py: 0 })).toEqual({ px: 7, py: 1 });
});
});