game-maker / suite (push) Successful in 10m22s
A state keeps its `in`/`out` exec pins in the model and the editor never draws
them. That is the whole trick: connecting, undo, deleting a state and saving all
keep working untouched, because `normalizeGraph` filters nodes and never edges,
and no edge ever checks its port key against a NodeDef. Only two places change --
`NodeView.redraw` stops drawing the row, and `portAt` refuses to hand one out, so
an invisible pin cannot be grabbed either.
Transitions are drawn by right-clicking a state, `Make transition`, then clicking
the target. The armed mode takes the same shape as a live drag, so the existing
preview draws it with no second code path, and it dies with Escape, a click on
empty canvas or a switch to another asset.
Removing the pins removed the only handle a transition had: edges have no
hit-test anywhere, and the pin right-click was the sole route to Disconnect.
The inspector's TRANSITIONS OUT / TRANSITIONS IN lists are therefore not a
convenience, they are the delete path.
`GraphEdge` gained an optional `label`, which `normalizeGraph` has to pass
through explicitly or a saved name is dropped on load. `State transition`
replaces `Next state`: it takes the machine on an input port and the name from a
picker, and resolves against the *current* state. Names are deliberately not
unique across a machine -- two states may both have an `alarm` -- so a node named
`alarm` moves whichever state has one, and an empty name follows the single
unnamed edge.
The geometry moved to a Pixi-free `transitions.ts`: a gap to the box border, and
a fan that puts a pair of opposite edges beside each other instead of on top of
each other. Keeping it as plain maths is what let a screenshot bug become a unit
test -- an earlier pin-to-pin line ran under the target node and took its
arrowhead with it, because `nodeLayer` sits above `edgeLayer`.
One limit stands and is not fixed here: `ConnectCommand` rejects a second edge
between the same two states ("Already connected"), because every transition
hangs off the same two ports. With names, `A --alarm--> B` beside
`A --seen--> B` is a sensible pair, so this is worth its own issue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
620 lines
26 KiB
TypeScript
620 lines
26 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { requireNodeDef } from '../../src/core/commands/require';
|
||
import { CommandError } from '../../src/core/commands/Command';
|
||
import {
|
||
AddNodeCommand,
|
||
DisconnectCommand,
|
||
RemoveNodeCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../src/core/commands/graphCommands';
|
||
import {
|
||
AddVariableCommand,
|
||
RemoveVariableCommand,
|
||
RenameVariableCommand,
|
||
} from '../../src/core/commands/variableCommands';
|
||
import {
|
||
GraphInterpreter,
|
||
allNodeDefs,
|
||
defAcceptsFrom,
|
||
dropTargetPort,
|
||
findNode,
|
||
firstPortFor,
|
||
NO_LOOKUP,
|
||
type PortType,
|
||
portsCompatible,
|
||
} from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||
import {
|
||
type ProjectFixture,
|
||
fakeContext,
|
||
fakeEntity,
|
||
graphEditor,
|
||
makeBehavior,
|
||
makeProject,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
} from '../helpers';
|
||
|
||
/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */
|
||
function withMover<F extends ProjectFixture>(fixture: F) {
|
||
const behavior = makeBehavior(fixture, 'Mover', ['speed']);
|
||
const edit = graphEditor(fixture, behavior);
|
||
const held = edit.addNode('OnKeyHeld', { key: 'ArrowRight' });
|
||
const owner = edit.addNode('Owner');
|
||
const speed = edit.variables(['speed']);
|
||
const mul = edit.addNode('Multiply');
|
||
const move = edit.addNode('Move');
|
||
edit.wire([
|
||
[held, 'exec', move, 'exec'],
|
||
[owner, 'object', move, 'object'],
|
||
[speed, 'speed', mul, 'a'],
|
||
[held, 'dt', mul, 'b'],
|
||
[mul, 'result', move, 'dx'],
|
||
]);
|
||
return { ...fixture, behavior, edit, nodes: { held, owner, speed, mul, move } };
|
||
}
|
||
|
||
/** A behavior "B" with the given number variables and an empty graph to edit. */
|
||
function emptyBehavior(variables: string[] = []) {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', variables);
|
||
return { ...fixture, behavior, edit: graphEditor(fixture, behavior) };
|
||
}
|
||
|
||
describe('node registry', () => {
|
||
it('registers the built-in node set with well-formed ports', () => {
|
||
const defs = allNodeDefs();
|
||
expect(defs.length).toBeGreaterThan(20);
|
||
for (const def of defs) {
|
||
const keys = [...def.inputs, ...def.outputs].map((p) => p.key);
|
||
expect(new Set(def.inputs.map((p) => p.key)).size).toBe(def.inputs.length);
|
||
expect(new Set(def.outputs.map((p) => p.key)).size).toBe(def.outputs.length);
|
||
// An event is seeded by the runtime, so nothing flows into it.
|
||
if (def.category === 'event') expect(def.inputs).toHaveLength(0);
|
||
// A state is a name in a machine; its ports only anchor transitions, so it never runs.
|
||
else if (def.category === 'state') expect(def.run).toBeUndefined();
|
||
// A shader node is compiled to GLSL and a particle node to a function: neither is ever run.
|
||
else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true);
|
||
else expect(def.run).toBeTypeOf('function');
|
||
expect(keys.every((k) => k.length > 0)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('type-checks port connections', () => {
|
||
expect(portsCompatible('exec', 'exec')).toBe(true);
|
||
expect(portsCompatible('exec', 'number')).toBe(false);
|
||
expect(portsCompatible('number', 'any')).toBe(true);
|
||
expect(portsCompatible('any', 'boolean')).toBe(true);
|
||
expect(portsCompatible('number', 'boolean')).toBe(false);
|
||
expect(portsCompatible('object', 'object')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('GraphInterpreter', () => {
|
||
it('runs an event pulse and pulls data lazily (speed × dt moves the owner)', () => {
|
||
const { behavior } = withMover(makeProject());
|
||
const interpreter = new GraphInterpreter(behavior.graph);
|
||
const ctx = fakeContext({ variables: { speed: 100 } });
|
||
const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
|
||
expect(fired).toBe(1);
|
||
expect(ctx.owner!.x).toBe(50);
|
||
expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0);
|
||
expect(ctx.owner!.x).toBe(50);
|
||
});
|
||
|
||
it('uses inline values for unconnected inputs', () => {
|
||
const { behavior, edit } = emptyBehavior();
|
||
const start = edit.addNode('OnStart');
|
||
const self = edit.addNode('Self');
|
||
const move = edit.addNode('Move', { dx: 7, dy: -3 });
|
||
edit.wire([
|
||
[start, 'exec', move, 'exec'],
|
||
[self, 'object', move, 'object'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.self.x).toBe(7);
|
||
expect(ctx.self.y).toBe(-3);
|
||
});
|
||
|
||
it('uses port defaults for unconnected inputs (Move scale = 1, Clamp 0…1)', () => {
|
||
const { behavior, edit } = emptyBehavior();
|
||
const start = edit.addNode('OnStart');
|
||
const self = edit.addNode('Self');
|
||
const clamp = edit.addNode('Clamp', { value: 5 });
|
||
const move = edit.addNode('Move', { dx: 4 });
|
||
edit.wire([
|
||
[start, 'exec', move, 'exec'],
|
||
[self, 'object', move, 'object'],
|
||
[clamp, 'result', move, 'dy'],
|
||
]);
|
||
const ctx = fakeContext();
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
|
||
expect(ctx.self.y).toBe(1); // clamp(5, 0, 1)
|
||
});
|
||
|
||
it('five-node mover: Axis input → Move(dt from On update) moves speed × dt, diagonals normalized', () => {
|
||
const { behavior, edit } = emptyBehavior(['speed']);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const axis = edit.addNode('AxisInput');
|
||
const speed = edit.variables(['speed']);
|
||
const move = edit.addNode('Move');
|
||
edit.wire([
|
||
[update, 'exec', move, 'exec'],
|
||
[update, 'dt', move, 'dt'],
|
||
[owner, 'object', move, 'object'],
|
||
[axis, 'x', move, 'dx'],
|
||
[axis, 'y', move, 'dy'],
|
||
[speed, 'speed', move, 'scale'],
|
||
]);
|
||
expect(behavior.graph.nodes).toHaveLength(5);
|
||
|
||
const interpreter = new GraphInterpreter(behavior.graph);
|
||
const run = (keys: string[]) => {
|
||
const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) });
|
||
interpreter.fire('OnUpdate', ctx, { dt: 0.5 });
|
||
return { x: ctx.owner!.x, y: ctx.owner!.y };
|
||
};
|
||
expect(run([])).toEqual({ x: 0, y: 0 });
|
||
expect(run(['ArrowRight'])).toEqual({ x: 50, y: 0 });
|
||
expect(run(['ArrowLeft'])).toEqual({ x: -50, y: 0 });
|
||
expect(run(['ArrowUp'])).toEqual({ x: 0, y: -50 });
|
||
const diagonal = run(['ArrowLeft', 'ArrowUp']);
|
||
expect(Math.hypot(diagonal.x, diagonal.y)).toBeCloseTo(50, 6); // same distance as a straight move
|
||
expect(diagonal.x).toBeCloseTo(-50 / Math.SQRT2, 6);
|
||
expect(diagonal.y).toBeCloseTo(-50 / Math.SQRT2, 6);
|
||
expect(run(['ArrowLeft', 'ArrowRight'])).toEqual({ x: 0, y: 0 }); // opposite keys cancel
|
||
});
|
||
|
||
it('vector and math helpers', () => {
|
||
const { behavior, edit } = emptyBehavior();
|
||
const start = edit.addNode('OnStart');
|
||
const sources = [
|
||
edit.addNode('Normalize', { x: 3, y: 4 }),
|
||
edit.addNode('Length', { x: 3, y: 4 }),
|
||
edit.addNode('Sqrt', { value: 16 }),
|
||
edit.addNode('Abs', { value: -2 }),
|
||
edit.addNode('Min', { a: 3, b: -1 }),
|
||
edit.addNode('Max', { a: 3, b: -1 }),
|
||
];
|
||
const ports = ['x', 'result', 'result', 'result', 'result', 'result'];
|
||
// One Log per source, chained after On start.
|
||
let previous = start;
|
||
sources.forEach((source, i) => {
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[previous, 'exec', log, 'exec'],
|
||
[source, ports[i]!, log, 'message'],
|
||
]);
|
||
previous = log;
|
||
});
|
||
const ctx = fakeContext();
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']);
|
||
});
|
||
|
||
it('follows Branch, writes variables and logs', () => {
|
||
const { behavior, edit } = emptyBehavior(['count']);
|
||
const start = edit.addNode('OnStart');
|
||
const get = edit.variables(['count']);
|
||
const cmp = edit.addNode('Compare', { op: '<', b: 3 });
|
||
const branch = edit.addNode('Branch');
|
||
const add = edit.addNode('Add', { b: 1 });
|
||
const set = edit.variables(['count'], { write: true });
|
||
const logThen = edit.addNode('Log', { message: 'then' });
|
||
const logElse = edit.addNode('Log', { message: 'else' });
|
||
edit.wire([
|
||
[start, 'exec', branch, 'exec'],
|
||
[get, 'count', cmp, 'a'],
|
||
[cmp, 'result', branch, 'condition'],
|
||
[branch, 'then', set, 'exec'],
|
||
[get, 'count', add, 'a'],
|
||
[add, 'result', set, 'count'],
|
||
[set, 'exec', logThen, 'exec'],
|
||
[branch, 'else', logElse, 'exec'],
|
||
]);
|
||
|
||
const interpreter = new GraphInterpreter(behavior.graph);
|
||
const ctx = fakeContext({ variables: { count: 0 } });
|
||
for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx);
|
||
expect(ctx.variables.count).toBe(3);
|
||
expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
|
||
});
|
||
|
||
it('reports traversed edges and nodes to the trace listener', () => {
|
||
const { behavior, nodes } = withMover(makeProject());
|
||
const interpreter = new GraphInterpreter(behavior.graph);
|
||
const edges: string[] = [];
|
||
const visited: string[] = [];
|
||
interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
|
||
interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 });
|
||
expect(edges).toHaveLength(1);
|
||
expect(visited).toContain(nodes.mul.id);
|
||
expect(visited).toContain(nodes.move.id);
|
||
});
|
||
|
||
it('stops a runaway flow instead of hanging', () => {
|
||
const { behavior, edit } = emptyBehavior();
|
||
const start = edit.addNode('OnStart');
|
||
const a = edit.addNode('Sequence');
|
||
const b = edit.addNode('Sequence');
|
||
edit.wire([
|
||
[start, 'exec', a, 'exec'],
|
||
[a, 'a', b, 'exec'],
|
||
[b, 'a', a, 'exec'],
|
||
]);
|
||
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(
|
||
/nested too deeply/,
|
||
);
|
||
});
|
||
|
||
it('stops a loop that would run forever', () => {
|
||
const { behavior, edit } = emptyBehavior(['count']);
|
||
const start = edit.addNode('OnStart');
|
||
const repeat = edit.addNode('Repeat', { times: 10_000_000 });
|
||
const log = edit.addNode('Log', { message: 'x' });
|
||
edit.wire([
|
||
[start, 'exec', repeat, 'exec'],
|
||
[repeat, 'body', log, 'exec'],
|
||
]);
|
||
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/);
|
||
});
|
||
});
|
||
|
||
describe('Object by name', () => {
|
||
/** Both graphs below name "Target"; only the way the name reaches the node differs. */
|
||
function lookup(nodeParams: Record<string, string>, wireName: boolean) {
|
||
const { behavior, edit } = emptyBehavior();
|
||
const start = edit.addNode('OnStart');
|
||
const byName = edit.addNode('ObjectByName', nodeParams);
|
||
const log = edit.addNode('Log');
|
||
edit.wire([
|
||
[start, 'exec', log, 'exec'],
|
||
[byName, 'object', log, 'message'],
|
||
]);
|
||
if (wireName) {
|
||
const join = edit.addNode('Join', { a: 'Tar', b: 'get' });
|
||
edit.wire([[join, 'text', byName, 'name']]);
|
||
}
|
||
const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
return ctx.logs;
|
||
}
|
||
|
||
it('still reads a name stored as a node setting, the way every saved project holds it', () => {
|
||
expect(lookup({ name: 'Target' }, false)).toEqual(['Target']);
|
||
});
|
||
|
||
it('takes a name put together at runtime', () => {
|
||
expect(lookup({}, true)).toEqual(['Target']);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A loop is the one place where the per-pulse data cache would lie: every round has to read its
|
||
* inputs anew, or the body would keep computing with the values of the first round.
|
||
*/
|
||
describe('Repeat', () => {
|
||
/** "count = count + index" in the body of a Repeat, so the sum proves each round saw its own index. */
|
||
function summingLoop(times: number) {
|
||
const fixture = emptyBehavior(['count']);
|
||
const { edit } = fixture;
|
||
const start = edit.addNode('OnStart');
|
||
const repeat = edit.addNode('Repeat', { times });
|
||
const get = edit.variables(['count']);
|
||
const add = edit.addNode('Add');
|
||
const set = edit.variables(['count'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', repeat, 'exec'],
|
||
[repeat, 'body', set, 'exec'],
|
||
[get, 'count', add, 'a'],
|
||
[repeat, 'index', add, 'b'],
|
||
[add, 'result', set, 'count'],
|
||
]);
|
||
return { ...fixture, nodes: { repeat, set } };
|
||
}
|
||
|
||
it('runs the body once per round, each round seeing its own index', () => {
|
||
const { behavior } = summingLoop(5);
|
||
const ctx = fakeContext({ variables: { count: 0 } });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
// 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead.
|
||
expect(ctx.variables.count).toBe(10);
|
||
});
|
||
|
||
it('runs nothing at all for a count of zero', () => {
|
||
const { behavior } = summingLoop(0);
|
||
const ctx = fakeContext({ variables: { count: 7 } });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.variables.count).toBe(7);
|
||
});
|
||
|
||
it('continues along done when the rounds are over', () => {
|
||
const { behavior, edit, nodes } = summingLoop(3);
|
||
const done = edit.addNode('Log', { message: 'done' });
|
||
edit.wire([[nodes.repeat, 'done', done, 'exec']]);
|
||
const ctx = fakeContext({ variables: { count: 0 } });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] });
|
||
});
|
||
|
||
it('nests: the inner loop starts over for every round of the outer one', () => {
|
||
const { behavior, edit } = emptyBehavior(['count']);
|
||
const start = edit.addNode('OnStart');
|
||
const outer = edit.addNode('Repeat', { times: 3 });
|
||
const inner = edit.addNode('Repeat', { times: 4 });
|
||
const get = edit.variables(['count']);
|
||
const add = edit.addNode('Add', { b: 1 });
|
||
const set = edit.variables(['count'], { write: true });
|
||
edit.wire([
|
||
[start, 'exec', outer, 'exec'],
|
||
[outer, 'body', inner, 'exec'],
|
||
[inner, 'body', set, 'exec'],
|
||
[get, 'count', add, 'a'],
|
||
[add, 'result', set, 'count'],
|
||
]);
|
||
const ctx = fakeContext({ variables: { count: 0 } });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
expect(ctx.variables.count).toBe(12);
|
||
});
|
||
|
||
it('leaves the world alone after the loop: what follows reads current values, not the last round', () => {
|
||
const { behavior, edit } = emptyBehavior(['count']);
|
||
const start = edit.addNode('OnStart');
|
||
const repeat = edit.addNode('Repeat', { times: 3 });
|
||
const get = edit.variables(['count']);
|
||
const add = edit.addNode('Add', { b: 1 });
|
||
const set = edit.variables(['count'], { write: true });
|
||
const after = edit.variables(['count'], { write: true });
|
||
const afterAdd = edit.addNode('Multiply', { b: 10 });
|
||
edit.wire([
|
||
[start, 'exec', repeat, 'exec'],
|
||
[repeat, 'body', set, 'exec'],
|
||
[get, 'count', add, 'a'],
|
||
[add, 'result', set, 'count'],
|
||
[repeat, 'done', after, 'exec'],
|
||
[get, 'count', afterAdd, 'a'],
|
||
[afterAdd, 'result', after, 'count'],
|
||
]);
|
||
const ctx = fakeContext({ variables: { count: 0 } });
|
||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||
// 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20.
|
||
expect(ctx.variables.count).toBe(30);
|
||
});
|
||
});
|
||
|
||
describe('graph commands', () => {
|
||
it('rejects incompatible, self and duplicate connections', () => {
|
||
const {
|
||
edit,
|
||
nodes: { held, mul, move },
|
||
} = withMover(makeProject());
|
||
expect(() => edit.connect(held, 'exec', mul, 'a')).toThrow(/Cannot connect/);
|
||
expect(() => edit.connect(mul, 'result', mul, 'a')).toThrow(/itself/);
|
||
expect(() => edit.connect(held, 'exec', move, 'exec')).toThrow(/Already/);
|
||
expect(() => edit.connect(held, 'nope', move, 'exec')).toThrow(/Port/);
|
||
});
|
||
|
||
it('replaces the edge of a data input and restores it on undo', () => {
|
||
const { behavior, stack, edit, nodes } = withMover(makeProject());
|
||
const literal = edit.addNode('Number', { value: 9 });
|
||
const before = behavior.graph.edges.length;
|
||
edit.connect(literal, 'value', nodes.move, 'dx');
|
||
expect(behavior.graph.edges.length).toBe(before); // one replaced, one added
|
||
const sourceOfDx = () =>
|
||
behavior.graph.edges.find((e) => e.to.node === nodes.move.id && e.to.port === 'dx')!.from.node;
|
||
expect(sourceOfDx()).toBe(literal.id);
|
||
stack.undo();
|
||
expect(sourceOfDx()).toBe(nodes.mul.id);
|
||
});
|
||
|
||
it('removing a node drops its edges; undo restores both', () => {
|
||
const { behavior, stack, nodes } = withMover(makeProject());
|
||
const edgesBefore = behavior.graph.edges.length;
|
||
stack.execute(new RemoveNodeCommand(behavior.id, nodes.mul.id));
|
||
expect(findNode(behavior.graph, nodes.mul.id)).toBeUndefined();
|
||
expect(behavior.graph.edges.length).toBe(edgesBefore - 3);
|
||
stack.undo();
|
||
expect(findNode(behavior.graph, nodes.mul.id)).toBeDefined();
|
||
expect(behavior.graph.edges.length).toBe(edgesBefore);
|
||
stack.execute(new DisconnectCommand(behavior.id, behavior.graph.edges[0]!.id));
|
||
expect(behavior.graph.edges.length).toBe(edgesBefore - 1);
|
||
stack.undo();
|
||
expect(behavior.graph.edges.length).toBe(edgesBefore);
|
||
});
|
||
|
||
it('coerces declared params and keeps inline input values', () => {
|
||
const { behavior, stack, nodes } = withMover(makeProject());
|
||
stack.execute(new SetNodeParamCommand(behavior.id, nodes.held.id, 'key', 'ArrowLeft'));
|
||
stack.execute(new SetNodeParamCommand(behavior.id, nodes.move.id, 'dy', 4));
|
||
const held = findNode(behavior.graph, nodes.held.id)!;
|
||
const move = findNode(behavior.graph, nodes.move.id)!;
|
||
expect(held.params.key).toBe('ArrowLeft');
|
||
expect(move.params.dy).toBe(4);
|
||
stack.undo();
|
||
expect(move.params.dy).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('variable commands', () => {
|
||
it('adds, renames (including graph references and overrides) and removes variables', () => {
|
||
const f = withMover(makeSpriteScene());
|
||
const { behavior, stack, nodes, object } = f;
|
||
const logic = place(f, behavior.id, { x: 0, y: 0 });
|
||
object(logic).overrides.speed = 5;
|
||
|
||
stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity'));
|
||
expect(behavior.variables[0]!.key).toBe('velocity');
|
||
expect(behavior.props).toEqual({ enabled: true, runInEditor: false, velocity: 0 });
|
||
expect(object(logic).overrides).toEqual({ velocity: 5 });
|
||
expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]);
|
||
stack.undo();
|
||
expect(behavior.variables[0]!.key).toBe('speed');
|
||
expect(object(logic).overrides).toEqual({ speed: 5 });
|
||
|
||
expect(() => stack.execute(new AddVariableCommand(behavior.id, 'speed', 'number'))).toThrow(/already/);
|
||
expect(() => stack.execute(new AddVariableCommand(behavior.id, '1bad', 'number'))).toThrow(/names/);
|
||
|
||
stack.execute(new RemoveVariableCommand(behavior.id, 'speed'));
|
||
expect(behavior.variables).toHaveLength(0);
|
||
expect(object(logic).overrides).toEqual({});
|
||
stack.undo();
|
||
expect(behavior.variables).toHaveLength(1);
|
||
expect(object(logic).overrides).toEqual({ speed: 5 });
|
||
});
|
||
});
|
||
|
||
describe('behavior serialization', () => {
|
||
it('round-trips variables and graph, dropping unknown node types and dangling edges', () => {
|
||
const { project, behavior } = withMover(makeProject());
|
||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||
expect(loaded.variables).toEqual(behavior.variables);
|
||
expect(loaded.graph).toEqual(behavior.graph);
|
||
expect(loaded.props).toEqual({ enabled: true, speed: 0 });
|
||
|
||
const raw = serializeProject(project) as {
|
||
assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[];
|
||
};
|
||
const rawGraph = raw.assets.find((a) => a.id === behavior.id)!.graph!;
|
||
rawGraph.nodes.push({ id: 'zzz', type: 'DoesNotExist', x: 0, y: 0, params: {} });
|
||
rawGraph.edges.push({
|
||
id: 'e-bad',
|
||
from: { node: 'zzz', port: 'exec' },
|
||
to: { node: behavior.graph.nodes[0]!.id, port: 'exec' },
|
||
});
|
||
const cleaned = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
|
||
expect(cleaned.graph.nodes.length).toBe(behavior.graph.nodes.length);
|
||
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
||
});
|
||
|
||
it('keeps a node param that is a whole list or a struct, not just a single value', () => {
|
||
const { project, behavior, edit } = emptyBehavior();
|
||
const list = edit.addNode('List', { value: [3, 1, 4] });
|
||
const struct = edit.addNode('Number', { bundle: { x: 2, tags: ['a'] } });
|
||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||
expect(findNode(loaded.graph, list.id)!.params.value).toEqual([3, 1, 4]);
|
||
expect(findNode(loaded.graph, struct.id)!.params.bundle).toEqual({ x: 2, tags: ['a'] });
|
||
});
|
||
});
|
||
|
||
describe('unknown node types', () => {
|
||
it('are rejected as a CommandError by execute, not thrown from the constructor', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
// Building the command must not throw: callers construct it outside the editor's
|
||
// error handling (`state.execute(new AddNodeCommand(…))`), so a throw here would crash.
|
||
const command = new AddNodeCommand(behavior.id, 'DoesNotExist', 0, 0);
|
||
expect(command.label).toBe('Add DoesNotExist');
|
||
expect(() => command.execute(fixture.project)).toThrow(CommandError);
|
||
expect(behavior.graph.nodes).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('dropping an edge on a node', () => {
|
||
/** A behavior with a number source and a Clamp, so a drag has several compatible inputs. */
|
||
function fixtureWithClamp() {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
const edit = graphEditor(fixture, behavior);
|
||
return { graph: behavior.graph, edit, number: edit.addNode('Number'), clamp: edit.addNode('Clamp') };
|
||
}
|
||
|
||
it('picks the first compatible input', () => {
|
||
const { graph, number, clamp } = fixtureWithClamp();
|
||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toEqual({
|
||
nodeId: clamp.id,
|
||
port: 'value',
|
||
isOutput: false,
|
||
});
|
||
});
|
||
|
||
it('skips inputs that already hold an edge', () => {
|
||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||
const other = edit.addNode('Number');
|
||
edit.connect(other, 'value', clamp, 'value');
|
||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('min');
|
||
});
|
||
|
||
it('falls back to the last compatible input once every one is taken', () => {
|
||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||
for (const port of ['value', 'min', 'max']) edit.connect(edit.addNode('Number'), 'value', clamp, port);
|
||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||
// Not free, but the drop still connects – it replaces the edge, as dropping on the port would.
|
||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)?.port).toBe('max');
|
||
});
|
||
|
||
it('keeps offering an exec input, which is never exclusive', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B');
|
||
const edit = graphEditor(fixture, behavior);
|
||
const held = edit.addNode('OnKeyHeld');
|
||
const move = edit.addNode('SetPosition');
|
||
edit.connect(edit.addNode('OnKeyHeld'), 'exec', move, 'exec');
|
||
const from = { nodeId: held.id, port: 'exec', isOutput: true };
|
||
expect(dropTargetPort(behavior.graph, from, move.id, NO_LOOKUP)?.port).toBe('exec');
|
||
});
|
||
|
||
it('refuses a node that offers nothing compatible, and the node itself', () => {
|
||
const { graph, edit, number, clamp } = fixtureWithClamp();
|
||
const boolean = edit.addNode('Boolean');
|
||
const from = { nodeId: number.id, port: 'value', isOutput: true };
|
||
expect(dropTargetPort(graph, from, boolean.id, NO_LOOKUP)).toBeNull();
|
||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toBeNull();
|
||
// A pair that is already wired is not offered again either.
|
||
edit.connect(number, 'value', clamp, 'value');
|
||
edit.connect(number, 'value', clamp, 'min');
|
||
edit.connect(number, 'value', clamp, 'max');
|
||
expect(dropTargetPort(graph, from, clamp.id, NO_LOOKUP)).toBeNull();
|
||
});
|
||
|
||
it('resolves a drag started at an input to an output', () => {
|
||
const { graph, number, clamp } = fixtureWithClamp();
|
||
const from = { nodeId: clamp.id, port: 'value', isOutput: false };
|
||
expect(dropTargetPort(graph, from, number.id, NO_LOOKUP)).toEqual({
|
||
nodeId: number.id,
|
||
port: 'value',
|
||
isOutput: true,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('the palette offered when an edge is dropped into empty space', () => {
|
||
const offers = (type: string, fromType: PortType, fromIsOutput: boolean) =>
|
||
defAcceptsFrom(requireNodeDef(type), fromType, fromIsOutput);
|
||
|
||
it('offers only nodes with a matching input when the drag started at an output', () => {
|
||
expect(offers('Move', 'exec', true)).toBe(true);
|
||
expect(offers('Clamp', 'number', true)).toBe(true);
|
||
// An exec output has no business at a data node, nor a number at a node without number inputs.
|
||
expect(offers('Number', 'exec', true)).toBe(false);
|
||
expect(offers('OnUpdate', 'number', true)).toBe(false);
|
||
});
|
||
|
||
it('looks at outputs instead when the drag started at an input', () => {
|
||
expect(offers('Number', 'number', false)).toBe(true);
|
||
expect(offers('OnUpdate', 'exec', false)).toBe(true);
|
||
expect(offers('Number', 'exec', false)).toBe(false);
|
||
expect(offers('StopGame', 'number', false)).toBe(false);
|
||
});
|
||
|
||
it('lets `any` match every data type, but never exec', () => {
|
||
expect(offers('Log', 'number', true)).toBe(true);
|
||
expect(offers('Log', 'string', true)).toBe(true);
|
||
expect(offers('ToText', 'exec', true)).toBe(false);
|
||
});
|
||
|
||
it('wires the picked node to its first compatible port', () => {
|
||
expect(firstPortFor(requireNodeDef('Clamp'), 'number', true)).toBe('value');
|
||
expect(firstPortFor(requireNodeDef('Move'), 'exec', true)).toBe('exec');
|
||
expect(firstPortFor(requireNodeDef('Number'), 'number', false)).toBe('value');
|
||
expect(firstPortFor(requireNodeDef('Number'), 'exec', true)).toBeNull();
|
||
});
|
||
});
|